Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111)

This commit is contained in:
Colin Maudry
2026-07-10 16:13:06 +02:00
21 changed files with 2347 additions and 16 deletions
+8 -4
View File
@@ -5,6 +5,14 @@ from src.api import routes
def init_api(server) -> None:
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
import os
from src.api import tokens_db, tracking
# Garantit que api_tokens existe avant que apply_pending (init_subscriptions,
# plus tard) ne tente l'ALTER de la migration 0007.
tokens_db.init_schema(os.environ["USERS_DB_PATH"])
server.config.setdefault("API_TITLE", "colibre API")
server.config.setdefault("API_VERSION", "v1")
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
@@ -27,8 +35,4 @@ def init_api(server) -> None:
api = Api(server)
api.register_blueprint(routes.bp)
import os
from src.api import tracking
tracking.start_worker(os.environ["USERS_DB_PATH"])
+3 -1
View File
@@ -16,7 +16,6 @@ def _abort_401(message: str):
def require_token(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(API_AUTH_DISABLED)
if not API_AUTH_DISABLED:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
@@ -30,6 +29,9 @@ def require_token(fn):
_abort_401("invalid_token")
if row["revoked_at"] is not None:
_abort_401("revoked_token")
if row["kind"] == "mcp":
# jeton dédié MCP : non valable sur l'API REST
_abort_401("invalid_token")
g.token_id = row["id"]
return fn(*args, **kwargs)
+29 -5
View File
@@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS api_tokens (
created_at TEXT NOT NULL,
last_used_at TEXT,
count_total INTEGER NOT NULL DEFAULT 0,
revoked_at TEXT
revoked_at TEXT,
kind TEXT NOT NULL DEFAULT 'api'
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"""
@@ -47,13 +48,15 @@ def init_schema(db_path) -> None:
conn.commit()
def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]:
def create_token(
db_path, label: str, user_id: int | None = None, kind: str = "api"
) -> tuple[str, int]:
token = TOKEN_PREFIX + secrets.token_hex(32)
with _connect(db_path) as conn:
cur = conn.execute(
"INSERT INTO api_tokens (token_hash, label, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(_hash(token), label, user_id, _utcnow_iso()),
"INSERT INTO api_tokens (token_hash, label, user_id, kind, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(_hash(token), label, user_id, kind, _utcnow_iso()),
)
conn.commit()
return token, cur.lastrowid
@@ -92,3 +95,24 @@ def list_tokens(db_path) -> list[dict]:
with _connect(db_path) as conn:
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
return [dict(r) for r in rows]
def list_user_tokens(db_path, user_id: int, kind: str = "mcp") -> list[dict]:
with _connect(db_path) as conn:
rows = conn.execute(
"SELECT * FROM api_tokens WHERE user_id = ? AND kind = ? "
"ORDER BY created_at DESC, id DESC",
(user_id, kind),
).fetchall()
return [dict(r) for r in rows]
def revoke_user_token(db_path, token_id: int, user_id: int) -> bool:
with _connect(db_path) as conn:
cur = conn.execute(
"UPDATE api_tokens SET revoked_at = ? "
"WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(_utcnow_iso(), token_id, user_id),
)
conn.commit()
return cur.rowcount > 0
+17
View File
@@ -124,6 +124,19 @@ if _mcp_enabled:
)
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
# Les routes /_mcp existent maintenant : exempter du CSRF (POST JSON-RPC
# externe sans jeton) puis brancher le garde d'abonnement.
from src.mcp.auth import init_mcp_auth # noqa: E402
if _auth_csrf is not None:
for _rule in app.server.url_map.iter_rules():
if _rule.rule.startswith("/_mcp"):
_vf = app.server.view_functions.get(_rule.endpoint)
if _vf is not None:
_auth_csrf.exempt(_vf)
init_mcp_auth(app.server)
from src.subscriptions.setup import init_subscriptions # noqa: E402
init_subscriptions(app.server)
@@ -136,6 +149,10 @@ from src.roadmap import db as roadmap_db # noqa: E402
roadmap_db.init_schema()
from src.mcp.account import mcp_account_bp # noqa: E402
app.server.register_blueprint(mcp_account_bp)
# robots.txt
@app.server.route("/robots.txt")
+48
View File
@@ -0,0 +1,48 @@
import os
from flask import Blueprint, redirect, request, session
from flask_login import current_user
from src.api import tokens_db
mcp_account_bp = Blueprint("mcp_account", __name__)
_LABEL_MAX = 100
def _has_active_subscription() -> bool:
# Réutilise la vérification canonique (gère TOUS_ABONNES).
from src.pages._compte_shell import current_user_has_subscription
return current_user_has_subscription()
def _guard():
"""Renvoie une redirection si l'accès est refusé, sinon None."""
if not current_user.is_authenticated:
return redirect("/connexion?next=/compte/mcp")
if not _has_active_subscription():
return redirect("/compte/abonnement")
return None
@mcp_account_bp.route("/compte/mcp/creer", methods=["POST"])
def creer():
denied = _guard()
if denied is not None:
return denied
label = (request.form.get("label") or "").strip()[:_LABEL_MAX] or "Sans nom"
token, _ = tokens_db.create_token(
os.environ["USERS_DB_PATH"], label, user_id=current_user.id, kind="mcp"
)
session["mcp_new_token"] = token
return redirect("/compte/mcp")
@mcp_account_bp.route("/compte/mcp/revoquer/<int:token_id>", methods=["POST"])
def revoquer(token_id):
denied = _guard()
if denied is not None:
return denied
tokens_db.revoke_user_token(os.environ["USERS_DB_PATH"], token_id, current_user.id)
return redirect("/compte/mcp")
+60
View File
@@ -0,0 +1,60 @@
import os
from flask import Flask, jsonify, request
from src.api import tokens_db
from src.subscriptions.db import has_active_subscription
from src.utils import TOUS_ABONNES
def _unauthorized():
resp = jsonify(
{"error": "unauthorized", "message": "Jeton MCP absent ou invalide."}
)
resp.status_code = 401
resp.headers["WWW-Authenticate"] = 'Bearer realm="colibre-mcp"'
return resp
def _forbidden():
resp = jsonify(
{
"error": "no_active_subscription",
"message": "Un abonnement colibre actif est requis pour le connecteur MCP.",
}
)
resp.status_code = 403
return resp
def _authenticate_mcp():
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return _unauthorized()
token = header[len("Bearer ") :].strip()
if not token:
return _unauthorized()
db_path = os.environ["USERS_DB_PATH"]
row = tokens_db.get_token_by_plaintext(db_path, token)
if row is None or row["revoked_at"] is not None or row["kind"] != "mcp":
return _unauthorized()
user_id = row["user_id"]
if user_id is None:
return _forbidden()
if not (TOUS_ABONNES or has_active_subscription(user_id)):
return _forbidden()
tokens_db.increment_usage(db_path, row["id"])
return None
def init_mcp_auth(server: Flask) -> None:
"""Enregistre le garde d'authentification du serveur MCP (/_mcp)."""
@server.before_request
def _guard_mcp():
if request.path == "/_mcp" or request.path.startswith("/_mcp/"):
return _authenticate_mcp()
return None
+12 -2
View File
@@ -42,6 +42,10 @@ _MIGRATIONS: list[tuple[str, str]] = [
"details TEXT, "
"created_at TEXT NOT NULL)",
),
(
"0007_add_kind_to_api_tokens",
"ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'",
),
]
@@ -62,9 +66,15 @@ def apply_pending() -> None:
except sqlite3.OperationalError as exc:
# Sur une DB fraîche (schéma déjà à jour), certaines migrations
# sont sans effet : ADD COLUMN → "duplicate column name",
# RENAME COLUMN → "no such column". On les ignore.
# RENAME COLUMN → "no such column", et un ALTER sur une table pas
# encore créée → "no such table" (elle sera créée plus tard par
# init_schema avec la colonne déjà dans SCHEMA).
err = str(exc)
if "duplicate column name" not in err and "no such column" not in err:
if (
"duplicate column name" not in err
and "no such column" not in err
and "no such table" not in err
):
raise
conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
+6
View File
@@ -17,6 +17,12 @@ SECTIONS = [
"href": "/compte/roadmap",
"require_subscription": True,
},
{
"key": "mcp",
"label": "Connecteur MCP",
"href": "/compte/mcp",
"require_subscription": True,
},
{
"key": "abonnement",
"label": "Abonnement",
+205
View File
@@ -0,0 +1,205 @@
import os
import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
from flask import session
from flask_login import current_user
from src.api import tokens_db
from src.pages._compte_shell import account_guard, account_shell
register_page(
__name__,
path="/compte/mcp",
title="Connecteur MCP | colibre",
name="Connecteur MCP",
description="Connectez votre agent IA aux données colibre via le protocole MCP.",
)
MCP_ENABLED = os.getenv("DASH_MCP_ENABLED") == "true"
_TOKEN_PLACEHOLDER = "<VOTRE_JETON>"
def _mcp_url() -> str:
base = os.getenv("APP_BASE_URL", "").rstrip("/")
return f"{base}/_mcp" if base else "/_mcp"
def _csrf(index: str):
return dcc.Input(
type="hidden",
id={"type": "csrf-input", "index": index},
name="csrf_token",
)
def client_instructions(url: str, token: str):
"""Construit les blocs d'instructions par client (fonction pure, testable)."""
claude = f'claude mcp add colibre --transport http {url} --header "Authorization: Bearer {token}"'
gemini = f'gemini mcp add --transport http --header "Authorization: Bearer {token}" colibre {url}'
return dbc.Accordion(
start_collapsed=True,
always_open=False,
children=[
dbc.AccordionItem(
title="Claude (Code / Desktop)",
children=html.Pre(html.Code(claude)),
),
dbc.AccordionItem(
title="Gemini CLI",
children=[
html.Pre(html.Code(gemini)),
html.P(
"Ou dans ~/.gemini/settings.json : un serveur mcpServers.colibre "
f'avec "httpUrl": "{url}" et "headers": '
f'{{"Authorization": "Bearer {token}"}}.'
),
],
),
dbc.AccordionItem(
title="Mistral Le Chat",
children=html.P(
"Dans les connecteurs MCP, ajoutez un serveur HTTP d'URL "
f"{url}, authentification « API Token », en-tête "
f"« Authorization » = « Bearer {token} »."
),
),
dbc.AccordionItem(
title="ChatGPT",
children=[
html.P(
"L'app ChatGPT grand public exige le flux OAuth 2.1 et "
"n'accepte pas de jeton statique. En attendant le connecteur "
"OAuth (itération future), utilisez la voie développeur "
"(API / Agents SDK OpenAI), qui accepte un serveur MCP distant "
f"d'URL {url} avec l'en-tête « Authorization: Bearer {token} »."
),
],
),
],
)
def _token_row(row: dict):
statut = "Révoqué" if row["revoked_at"] else "Actif"
actions = []
if not row["revoked_at"]:
actions = [
html.Form(
method="POST",
action=f"/compte/mcp/revoquer/{row['id']}",
children=[
_csrf(f"revoke-{row['id']}"),
dbc.Button(
"Révoquer",
type="submit",
color="danger",
size="sm",
outline=True,
),
],
)
]
return html.Tr(
[
html.Td(row["label"]),
html.Td(row["created_at"]),
html.Td(row["last_used_at"] or ""),
html.Td(statut),
html.Td(actions),
]
)
def layout(**_):
guard = account_guard("/compte/mcp", require_subscription=True)
if guard is not None:
return guard
if not MCP_ENABLED:
contenu = html.Div(
[
html.H2("Connecteur MCP"),
dbc.Alert(
"Le connecteur MCP sera bientôt disponible. Revenez prochainement.",
color="info",
),
]
)
return account_shell("mcp", contenu)
url = _mcp_url()
new_token = session.pop("mcp_new_token", None)
alerts = []
if new_token:
alerts.append(
dbc.Alert(
[
html.Strong("Votre nouveau jeton (copiez-le maintenant, "),
html.Strong("il ne sera plus affiché) :"),
html.Pre(html.Code(new_token)),
],
color="success",
)
)
tokens = tokens_db.list_user_tokens(
os.environ["USERS_DB_PATH"], current_user.id, "mcp"
)
table = (
dbc.Table(
[
html.Thead(
html.Tr(
[
html.Th("Nom"),
html.Th("Créé le"),
html.Th("Dernière utilisation"),
html.Th("Statut"),
html.Th(""),
]
)
),
html.Tbody([_token_row(dict(r)) for r in tokens]),
],
striped=True,
bordered=False,
hover=True,
)
if tokens
else html.P("Aucun jeton pour le moment.")
)
create_form = html.Form(
method="POST",
action="/compte/mcp/creer",
className="mb-4",
children=[
_csrf("mcp-create"),
dbc.Label("Nom du jeton (ex. « Claude sur mon portable »)"),
dbc.Input(type="text", name="label", required=True, className="mb-2"),
dbc.Button("Générer un jeton", type="submit", color="primary"),
],
)
snippet_token = new_token or _TOKEN_PLACEHOLDER
contenu = html.Div(
[
html.H2("Connecteur MCP"),
html.P(
"Générez un jeton pour connecter votre agent IA (Claude, Gemini, "
"Mistral…) aux données colibre via le protocole MCP. Le jeton vaut "
"votre identité : gardez-le secret. Un abonnement actif est requis."
),
*alerts,
html.H4("Générer un jeton", className="mt-3"),
create_form,
html.H4("Mes jetons", className="mt-4"),
table,
html.H4("Connecter un client", className="mt-4"),
html.P(f"URL du serveur MCP : {url}"),
client_instructions(url, snippet_token),
]
)
return account_shell("mcp", contenu)