Files
flowdeck/app/routers/mcp_server.py
T
Spark 19b27698f2
FlowDeck CI / test (push) Failing after 3h11m23s
FlowDeck CI / lint (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
feat(fusion): serveur MCP complété (ACC-5.1) + pont MCP ObsiGate (ACC-2.2) — 7.83.0
ACC-5.1 (#24) — app/routers/mcp_server.py : catalogue §7.2 complété
(list_projects, get_project, read_page tronqué proprement,
list_project_tasks), search_pages en plein texte, liens profonds +
kind sur chaque résultat, scopes du jeton appliqués (écriture
refusée -32002 sans scope write) et journalisation §7.4 de chaque
tools/call dans api_audit_log (clés d'arguments seulement).

ACC-2.2 (#20) — pont MCP Flowdeck → ObsiGate, côté client :
- mcp_client : serveurs à session (capture/réémission de
  Mcp-Session-Id, handshake avant tools/call), décodage SSE
  multi-messages par id de réponse, classification des outils à la
  découverte (readOnlyHint sinon heuristique ; lecture par défaut).
- preset connecteur « obsigate » (URL dérivée dev/prod), kinds MCP
  factorisés (connectors.MCP_KINDS), option dans le panneau agent.
- garde-fou n° 2 : permission_manager.is_write_tool couvre les
  outils MCP écrivants → portes politique d'approbation
  (agent_engine) et rôles (assert_can) ; McpTool bloque tout
  apply_* distant (patron §7.5).

Tests : test_fusion_mcp_server 18, nouveau test_fusion_pont_obsigate
7 (faux serveur ObsiGate à session stricte via le vrai _rpc).
Preuve réelle T0-T5 : attend la clé ObsiGate dédiée (1 vault) et
Q-A/Q-B côté Bruno.
2026-10-26 12:38:04 -04:00

660 lines
33 KiB
Python

"""FlowDeck — serveur MCP (Fusion, branchement du service d'agents).
Même dialecte JSON-RPC que les autres apps du programme (TikTak,
Kanbante) : POST /mcp, initialize → tools/list → tools/call,
réponses en contenu texte JSON.
Authentification : les jetons d'API personnels existants
(Settings → API tokens, table ``api_tokens``, vérifiés par
``public_api._token_owner``) — aucune nouvelle machinerie de clés.
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
il lit et écrit en son nom.
Objets exposés : les **workspaces** (conteneurs de pages) et leurs
pages, les **collections** (bases de données type Notion — ne pas
confondre : une collection vit dans un workspace ou une page), la
recherche par titre, et le registre des dépôts (table ``projects``).
Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression.
**ACC-5.1 (architecture v2.0, §7.2)** — le catalogue de lecture cible
est couvert : ``list_projects`` / ``get_project`` (un « projet » du
contrat de liens profonds est une collection : ``/collections/<id>``),
``search_pages`` (plein texte : titres **et** contenus, pages
classiques et lignes de collections), ``read_page`` (contenu tronqué
proprement) et ``list_project_tasks`` (collections ``is_task``). Les
outils réunions restent absents (décision Meetings A/B ouverte).
Chaque résultat d'objet porte son **URL profonde** (forme figée par
``contrats/liens-profonds.md``), comme le §7.2 l'exige.
**§7.4** — chaque ``tools/call`` est journalisé dans
``api_audit_log`` (jeton, outil, horodatage) et la **portée du
jeton** est appliquée : sans le scope ``write``, les trois outils
d'écriture sont refusés (« jeton en lecture seule »).
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Request
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 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 "
"(titres, parent, section) — pages classiques, pas les "
"lignes des collections.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "list_collections",
"description": "Liste les COLLECTIONS Flowdeck — les bases de données "
"de type Notion (tables, CRM, calendriers, formulaires), "
"avec leur nombre de lignes. Ce ne sont PAS les workspaces : "
"une collection vit dans un workspace ou dans une page.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_collection",
"description": "Lit une collection et ses pages (titres et propriétés).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "get_page",
"description": "Lit une page de collection par son identifiant (titre, propriétés, contenu).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "search_pages",
"description": "Recherche des pages par mots dans leur titre, toutes collections confondues.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]}},
{"name": "list_repos",
"description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "list_projects",
"description": "Liste les PROJETS Flowdeck du périmètre du jeton "
"(catalogue §7.2) : au sens du contrat de liens "
"profonds, un projet est une collection — id, titre, "
"état (is_task, nombre de lignes) et URL profonde "
"/collections/<id>.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_project",
"description": "Détail d'un projet (collection) SANS le contenu "
"complet de ses pages : métadonnées, schéma des "
"propriétés et titres de ses lignes, URL profonde.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "read_page",
"description": "Lit le contenu d'une page (§7.2), tronqué "
"proprement à 20 000 caractères (drapeau "
"« truncated »). « kind » départage les deux "
"familles d'identifiants : « page » (page "
"classique d'un workspace, URL /pages/<id>) ou "
"« collection_page » (ligne d'une collection, "
"contenu porté par sa page liée). Sans « kind », "
"la page classique est essayée d'abord.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"},
"kind": {"type": "string",
"enum": ["page", "collection_page"]}},
"required": ["id"]}},
{"name": "list_project_tasks",
"description": "Liste les tâches des projets « base de tâches » "
"(collections is_task) du périmètre du jeton : "
"titre, statut, échéance, assignés, URL profonde "
"du projet. « project_id » restreint à un projet.",
"inputSchema": {"type": "object",
"properties": {"project_id": {"type": "integer"}}}},
{"name": "create_page",
"description": "Crée une page dans une collection Flowdeck.",
"inputSchema": {"type": "object",
"properties": {"collection_id": {"type": "integer"},
"title": {"type": "string"},
"icon": {"type": "string"},
"properties": {"type": "object"}},
"required": ["collection_id", "title"]}},
{"name": "update_page",
"description": "Met à jour une page Flowdeck (titre et/ou propriétés ; "
"les propriétés fournies remplacent property_values_json).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object"}},
"required": ["id"]}},
{"name": "create_collection",
"description": "Crée une collection Flowdeck (projet / espace de travail).",
"inputSchema": {"type": "object",
"properties": {"name": {"type": "string"},
"description": {"type": "string"},
"icon": {"type": "string"}},
"required": ["name"]}},
]
#: Outils d'écriture — refusés aux jetons sans le scope ``write`` (§7.4).
OUTILS_ECRITURE = {"create_page", "update_page", "create_collection"}
#: Borne de lecture d'un contenu de page (§7.2 : « tronqué proprement »).
CONTENU_MAX = 20000
def _url_projet(collection_id: int) -> str:
"""URL profonde d'un projet (contrat figé : /collections/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/collections/{collection_id}"
def _url_page(page_id: int) -> str:
"""URL profonde d'une page classique (contrat figé : /pages/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/pages/{page_id}"
def _tronquer(texte: str) -> tuple[str, bool]:
"""Troncature propre : jamais au milieu d'un mot si on peut l'éviter."""
texte = texte or ""
if len(texte) <= CONTENU_MAX:
return texte, False
coupe = texte[:CONTENU_MAX]
dernier_espace = coupe.rfind(" ", CONTENU_MAX - 200)
if dernier_espace > 0:
coupe = coupe[:dernier_espace]
return coupe, True
def _props(brut):
"""property_values_json → dict quand c'est du JSON, sinon inchangé."""
if not brut:
return {}
try:
return json.loads(brut)
except (ValueError, TypeError):
return brut
def _page_resume(row) -> dict:
return {"id": row["id"], "collection_id": row["collection_id"],
"kind": "collection_page",
"title": row["title"], "icon": row["icon"],
"properties": _props(row["property_values_json"]),
"url": _url_projet(row["collection_id"]),
"updated_at": row["updated_at"]}
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 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 or ws["id"] not in accessible:
return {"erreur": "workspace introuvable"}
pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at "
"FROM pages WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall()
return {"workspace": {"id": ws["id"], "name": ws["name"],
"owner_id": ws["owner_id"]},
"pages": [dict(p) for p in pages]}
if nom == "list_collections":
rows = conn.execute(
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,
"url": _url_projet(r["id"]),
"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 or not collection_visible(coll["id"]):
return {"erreur": "collection introuvable"}
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
"ORDER BY position, id", (coll["id"],)).fetchall()
return {"collection": {"id": coll["id"], "name": coll["name"],
"description": coll["description"],
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"])},
"pages": [_page_resume(p) for p in pages]}
if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
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"):
if cle in p.keys() and p[cle]:
res[cle] = p[cle]
return res
if nom == "search_pages":
# §7.2 : recherche plein texte — titres ET contenus, pages
# classiques comme lignes de collections, URL profonde et
# famille (« kind ») sur chaque résultat pour que read_page
# puisse être appelé sans ambiguïté d'identifiant.
terme = str(args.get("query", "")).strip()
if not terme:
return []
q = f"%{terme}%"
resultats: list[dict] = []
rows = conn.execute(
"SELECT * FROM collection_pages "
"WHERE title LIKE ? OR property_values_json LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in rows:
if collection_visible(p["collection_id"]):
resultats.append(_page_resume(p))
pages = conn.execute(
"SELECT id, title, workspace_id, updated_at FROM pages "
"WHERE deleted_at IS NULL AND (title LIKE ? OR content LIKE ?) "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in pages:
if p["workspace_id"] is None or p["workspace_id"] in accessible:
resultats.append({
"id": p["id"], "kind": "page", "title": p["title"],
"workspace_id": p["workspace_id"],
"url": _url_page(p["id"]),
"updated_at": p["updated_at"]})
return resultats[:50]
if nom == "list_repos":
rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description "
"FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
if nom == "list_projects":
# §7.2 : un « projet » du contrat de liens profonds est une
# collection. Même périmètre et même forme que
# list_collections, vocabulaire du catalogue cible.
rows = conn.execute(
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"], "title": 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"],
"url": _url_projet(r["id"]),
"updated_at": r["updated_at"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_project":
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if coll is None or not collection_visible(coll["id"]):
return {"erreur": "projet introuvable"}
schema = _props(coll["schema_json"]) \
if coll["schema_json"] else []
titres = conn.execute(
"SELECT id, title FROM collection_pages "
"WHERE collection_id = ? ORDER BY position, id LIMIT 300",
(coll["id"],)).fetchall()
return {"project": {"id": coll["id"], "title": coll["name"],
"description": coll["description"],
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"]),
"updated_at": coll["updated_at"]},
"schema": schema,
"pages": [{"id": t["id"], "title": t["title"]}
for t in titres]}
if nom == "read_page":
try:
pid = int(args.get("id"))
except (TypeError, ValueError):
return {"erreur": "id requis (entier)"}
kind = str(args.get("kind") or "")
if kind in ("page", ""):
page = conn.execute(
"SELECT * FROM pages WHERE id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if page is not None and (
page["workspace_id"] is None
or page["workspace_id"] in accessible):
contenu, tronque = _tronquer(page["content"] or "")
return {"id": page["id"], "kind": "page",
"title": page["title"],
"workspace_id": page["workspace_id"],
"content": contenu, "truncated": tronque,
"url": _url_page(page["id"]),
"updated_at": page["updated_at"]}
if kind == "page":
return {"erreur": "page introuvable"}
if kind in ("collection_page", ""):
p = conn.execute(
"SELECT * FROM collection_pages WHERE id = ?",
(pid,)).fetchone()
if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"}
res = _page_resume(p)
liee = conn.execute(
"SELECT content FROM pages "
"WHERE collection_row_id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if liee is not None:
contenu, tronque = _tronquer(liee["content"] or "")
res["content"] = contenu
res["truncated"] = tronque
return res
return {"erreur": f"kind inconnu : {kind}"}
if nom == "list_project_tasks":
from app.services.task_databases import collect_tasks, is_done
projet_id = args.get("project_id")
if projet_id is not None:
try:
projet_id = int(projet_id)
except (TypeError, ValueError):
return {"erreur": "project_id doit être un entier"}
cible = conn.execute(
"SELECT id, is_task FROM collections WHERE id = ?",
(projet_id,)).fetchone()
if cible is None or not collection_visible(projet_id):
return {"erreur": "projet introuvable"}
if "is_task" in cible.keys() and not cible["is_task"]:
return {"erreur": "ce projet n'est pas une base de tâches"}
taches = collect_tasks(conn, user_id, mine_only=False)
sortie = []
for t in taches:
if projet_id is not None and t["collection_id"] != projet_id:
continue
sortie.append({
"id": t["id"], "title": t["title"],
"status": t["status"], "done": t["done"],
"due": t["due"], "assignees": t["assignees"],
"project_id": t["collection_id"],
"project_name": t["collection_name"],
"url": _url_projet(t["collection_id"])})
if projet_id is not None and not sortie:
# Base de tâches non « configurée » (mapping My Tasks
# incomplet) : collect_tasks la saute par conception,
# mais le serveur MCP répond quand même — lignes brutes,
# statut lu sur la propriété mappée ou nommée Statut.
coll = conn.execute(
"SELECT * FROM collections WHERE id = ?",
(projet_id,)).fetchone()
prop_statut = None
if "task_status_prop" in coll.keys() and coll["task_status_prop"]:
prop_statut = coll["task_status_prop"]
else:
pr = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id = ? AND LOWER(name) IN "
"('statut', 'status', 'état', 'etat') "
"ORDER BY position LIMIT 1", (projet_id,)).fetchone()
if pr is not None:
prop_statut = pr["id"]
lignes = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
"ORDER BY position, id", (projet_id,)).fetchall()
for ligne in lignes:
props = _props(ligne["property_values_json"])
statut = ""
if isinstance(props, dict) and prop_statut is not None:
v = props.get(prop_statut, props.get(str(prop_statut), ""))
if isinstance(v, list):
v = v[0] if v else ""
statut = str(v or "")
sortie.append({
"id": ligne["id"], "title": ligne["title"],
"status": statut, "done": is_done(statut),
"due": "", "assignees": [],
"project_id": projet_id,
"project_name": coll["name"],
"url": _url_projet(projet_id)})
return sortie
if nom == "create_page":
try:
collection_id = int(args.get("collection_id"))
except (TypeError, ValueError):
return {"erreur": "collection_id requis (entier)"}
title = str(args.get("title") or "").strip()
if not title:
return {"erreur": "titre vide"}
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(collection_id,)).fetchone()
if coll is None or not collection_visible(collection_id):
return {"erreur": "collection introuvable"}
props = args.get("properties")
if props is None:
props = {}
if not isinstance(props, dict):
return {"erreur": "properties doit être un objet"}
icon = str(args.get("icon") or "file")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
"WHERE collection_id = ?", (collection_id,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages "
"(collection_id, title, icon, position, property_values_json) "
"VALUES (?, ?, ?, ?, ?)",
(collection_id, title, icon, max_pos, json.dumps(props, ensure_ascii=False)))
conn.commit()
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(cur.lastrowid,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page['id']}", title,
url=url_collection(collection_id), actor="agent:mcp")
return _page_resume(page)
if nom == "update_page":
try:
page_id = int(args.get("id"))
except (TypeError, ValueError):
return {"erreur": "id requis (entier)"}
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
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:
title = str(args.get("title") or "").strip()
if not title:
return {"erreur": "titre vide"}
props_json = page["property_values_json"]
if "properties" in args and args.get("properties") is not None:
props = args.get("properties")
if not isinstance(props, dict):
return {"erreur": "properties doit être un objet"}
props_json = json.dumps(props, ensure_ascii=False)
conn.execute(
"UPDATE collection_pages SET title = ?, property_values_json = ?, "
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(title, props_json, page_id))
conn.commit()
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(page["collection_id"]), actor="agent:mcp")
return _page_resume(updated)
if nom == "create_collection":
name = str(args.get("name") or "").strip()
if not name:
return {"erreur": "nom vide"}
description = str(args.get("description") or "")
icon = str(args.get("icon") or "📋")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) "
"VALUES (?, ?, ?, '[]')",
(name, description, icon))
new_id = cur.lastrowid
# Vue par défaut, comme la création via l'API collections
# (app/routers/collections/crud.py).
conn.execute(
"INSERT INTO collection_views "
"(collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
(new_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"], "sorts": [], "filters": []})))
conn.commit()
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(new_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
url=url_collection(new_id), actor="agent:mcp")
return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"],
"url": _url_projet(coll["id"])}
return {"erreur": f"outil inconnu : {nom}"}
def _version() -> str:
import pathlib
try:
return (pathlib.Path(__file__).resolve().parent.parent.parent
/ "VERSION").read_text(encoding="utf-8").strip()
except OSError:
return "dev"
def _scopes_jeton(token_id: int) -> str:
"""Scopes du jeton MCP (colonne ``api_tokens.scopes``, défaut hérité
``read,write`` posé par la migration v6.3.0)."""
try:
with get_conn() as conn:
row = conn.execute("SELECT scopes FROM api_tokens WHERE id = ?",
(token_id,)).fetchone()
if row is not None and "scopes" in row.keys() and row["scopes"]:
return str(row["scopes"])
except Exception: # noqa: BLE001 — base ancienne sans la colonne
pass
return "read,write"
def _journaliser(proprietaire: dict, outil: str, arguments: dict,
request: Request, statut: str) -> None:
"""Journal d'audit §7.4 : qui (jeton), quel outil, quand — les
valeurs d'arguments ne sont pas journalisées, seules leurs clés."""
try:
from app.services.api_v2_helpers import audit_log
cles = ",".join(sorted(str(k) for k in (arguments or {}).keys()))
audit_log({"id": proprietaire["user_id"],
"_token_id": proprietaire["id"]},
f"mcp:{outil}", "mcp_tool", outil,
f"statut={statut}; jeton={proprietaire.get('name', '')}; args=[{cles}]",
request)
except Exception: # noqa: BLE001 — l'audit ne casse jamais l'appel
pass
@router.get("/mcp")
def mcp_info():
noms = [t["name"] for t in OUTILS]
return {"name": "flowdeck", "version": _version(),
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "tools": noms,
"read_tools": [n for n in noms if n not in OUTILS_ECRITURE],
"write_tools": [n for n in noms if n in OUTILS_ECRITURE]}
@router.post("/mcp")
async def mcp(request: Request):
auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
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)"}},
status_code=401)
try:
data = await request.json()
except Exception:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "JSON invalide"}},
status_code=400)
methode = data.get("method", "")
rid = data.get("id")
if rid is None: # notification : rien à répondre
return Response(status_code=202)
if methode == "initialize":
return {"jsonrpc": "2.0", "id": rid,
"result": {"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "flowdeck", "version": _version()}}}
if methode == "ping":
return {"jsonrpc": "2.0", "id": rid, "result": {}}
if methode == "tools/list":
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call":
params = data.get("params") or {}
outil = params.get("name", "")
arguments = params.get("arguments") or {}
# §7.4 : la portée du jeton fait la loi — un jeton « read »
# (ex. clé dédiée à un pont de lecture) ne peut pas écrire.
if outil in OUTILS_ECRITURE:
from app.services.api_v2_helpers import has_scope
if not has_scope(_scopes_jeton(proprietaire["id"]), "write"):
_journaliser(proprietaire, outil, arguments, request,
"refuse_portee")
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32002,
"message": "Jeton en lecture seule : "
"l'outil d'écriture "
f"« {outil} » exige le scope write"}}
res = _appel_outil(outil, arguments, proprietaire["user_id"])
_journaliser(proprietaire, outil, arguments, request,
"erreur" if isinstance(res, dict) and "erreur" in res
else "ok")
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"Méthode inconnue : {methode}"}}