Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111)
This commit is contained in:
+8
-4
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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 (?, ?)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user