7.75.0 — Fusion : serveur MCP lecture seule + panneau Spark
- POST /mcp (JSON-RPC, dialecte TikTak/Kanbante) : list_collections, get_collection, get_page, search_pages, list_repos ; auth par les jetons d'API personnels existants (api_tokens) - Panneau Spark vendangé de TikTak, branché sur l'action spark de la barre commune ; CSP connect-src : exception nommée agent.dracodev.net - Tests : test_fusion_mcp_server (3) verts ; audit CSP mis à jour ; 2 échecs locaux préexistants (DNS) inchangés
This commit is contained in:
@@ -291,8 +291,13 @@ def test_csp_no_cdn_and_vendor(client):
|
||||
import re as _re
|
||||
|
||||
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
|
||||
assert conn == "'self' ws://testserver wss://testserver", conn
|
||||
assert " https:" not in conn and not conn.startswith("https:"), conn
|
||||
# Exception Fusion (2026-10-21, 7.75.0) : le panneau Spark parle au
|
||||
# service d'agents commun — seul hôte externe nommé autorisé.
|
||||
assert conn == "'self' https://agent.dracodev.net ws://testserver wss://testserver", conn
|
||||
# La source générique « https: » (tous hôtes) reste interdite ; seul
|
||||
# l'hôte nommé du service d'agents est admis (voir l'assertion
|
||||
# d'égalité ci-dessus, qui verrouille la liste complète).
|
||||
assert "https:" not in conn.split(), conn
|
||||
|
||||
# vues chart/map : références locales (aucun CDN — A28 : le fichier
|
||||
# collections.py est devenu un package, on balaie tous ses modules)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Fusion — serveur MCP Flowdeck (branchement du service d'agents).
|
||||
|
||||
POST /mcp en lecture seule, authentifié par les jetons d'API
|
||||
personnels existants (api_tokens). Le service d'agents lit les
|
||||
collections et leurs pages au nom du propriétaire du jeton.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def jeton(client):
|
||||
brut = "fd_test_token_pour_mcp"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
|
||||
"VALUES (1, 'service-agents', ?, ?, 0)",
|
||||
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
|
||||
conn.commit()
|
||||
return brut
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def donnees(client):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon) VALUES ('Projet Alpha', 'Le projet de test', '🚀')")
|
||||
cid = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
|
||||
"VALUES (?, 'Page de cadrage', 'file', 1, ?)",
|
||||
(cid, json.dumps({"statut": "en cours"})))
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
|
||||
"VALUES (?, 'Revue hebdo', 'file', 2, '{}')", (cid,))
|
||||
conn.commit()
|
||||
return cid
|
||||
|
||||
|
||||
def _rpc(client, jeton, rid, methode, params=None):
|
||||
h = {"Authorization": f"Bearer {jeton}"} if jeton else {}
|
||||
corps = {"jsonrpc": "2.0", "id": rid, "method": methode}
|
||||
if params is not None:
|
||||
corps["params"] = params
|
||||
return client.post("/mcp", json=corps, headers=h)
|
||||
|
||||
|
||||
def _appel(client, jeton, rid, nom, args=None):
|
||||
r = _rpc(client, jeton, rid, "tools/call", {"name": nom, "arguments": args or {}})
|
||||
assert r.status_code == 200, r.text
|
||||
return json.loads(r.json()["result"]["content"][0]["text"])
|
||||
|
||||
|
||||
def test_mcp_sans_jeton_401(client):
|
||||
r = _rpc(client, None, 1, "tools/list")
|
||||
assert r.status_code == 401
|
||||
r = _rpc(client, "mauvais-jeton", 1, "tools/list")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_mcp_handshake_et_outils(client, jeton):
|
||||
r = _rpc(client, jeton, 1, "initialize", {})
|
||||
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
|
||||
r = _rpc(client, jeton, 2, "tools/list")
|
||||
noms = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert noms == ["list_collections", "get_collection", "get_page",
|
||||
"search_pages", "list_repos"]
|
||||
|
||||
|
||||
def test_mcp_collections_et_pages(client, jeton, donnees):
|
||||
cols = _appel(client, jeton, 1, "list_collections")
|
||||
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
|
||||
assert alpha and alpha[0]["page_count"] == 2
|
||||
detail = _appel(client, jeton, 2, "get_collection", {"id": donnees})
|
||||
assert detail["collection"]["name"] == "Projet Alpha"
|
||||
titres = [p["title"] for p in detail["pages"]]
|
||||
assert titres == ["Page de cadrage", "Revue hebdo"]
|
||||
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
|
||||
res = _appel(client, jeton, 3, "search_pages", {"query": "cadrage"})
|
||||
assert [p["title"] for p in res] == ["Page de cadrage"]
|
||||
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
|
||||
assert "introuvable" in res["erreur"]
|
||||
Reference in New Issue
Block a user