# Connecteur MCP (scope B, #111) — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Conditionner l'accès au serveur MCP colibre (`/_mcp`) à un abonnement actif, via un jeton Bearer statique dédié que l'abonné génère depuis `/compte/mcp` (« Connecteur MCP »). **Architecture:** Réutilise la table `api_tokens` (jetons `colibre_…` hachés) en ajoutant une colonne `kind` (`'api'`|`'mcp'`). Un garde Flask `before_request` sur `/_mcp` valide le jeton (`kind='mcp'`, non révoqué) puis l'abonnement actif du propriétaire. Une page compte gérée par formulaires Flask (POST + CSRF) permet de générer/révoquer les jetons et documente la connexion pour Claude, Gemini, Mistral et ChatGPT. **Tech Stack:** Python, Flask, Flask-Login, Flask-WTF (CSRF), Dash 4.4 (`use_pages`), SQLite, Dash MCP (scope A déjà livré). ## Global Constraints - Imports internes toujours préfixés `src.` (ex. `from src.api import tokens_db`). - Copie UI en français ; libellé de section exact : **« Connecteur MCP »**. - Lancer les tests avec **`uv run pytest`** (l'activation venv via Bash n'est pas fiable ici). - Avant tout `git add`/`commit`, exécuter **`pre-commit`** (ruff + prettier) ; si des fichiers sont reformatés, les ré-ajouter avant de committer. - Chaque tâche ne lance QUE le fichier de test qui la concerne. Le `uv run pytest` complet (sans chemin) est réservé à la dernière tâche. - Jetons : préfixe `colibre_`, SHA-256 stocké (jamais le clair), clair affiché **une seule fois**. - `kind` ∈ {`'api'`, `'mcp'`}, défaut `'api'`. Les jetons CLI existants restent `'api'`. - Garde `/_mcp` : **401** (jeton absent/invalide/révoqué/`kind≠'mcp'`) avec en-tête `WWW-Authenticate: Bearer realm="colibre-mcp"` ; **403** (jeton `mcp` valide mais `user_id` nul ou abonnement inactif). - Vérification d'abonnement = `TOUS_ABONNES or has_active_subscription(user_id)` (même sémantique que le reste de l'app). - Migrations : convention `NNNN_description`, la prochaine est **`0007`** (la dernière est `0006_create_admin_actions`). - `DASH_MCP_ENABLED` reste `false` par défaut ; ne pas le forcer dans le code ni dans les tests. ## File Structure | Fichier | Rôle | Tâche | | ------------------------------------------- | ------------------------------------------------------------------------------ | ----- | | `src/api/tokens_db.py` (modif) | Colonne `kind`, `create_token(kind=)`, `list_user_tokens`, `revoke_user_token` | 1 | | `src/migrations.py` (modif) | Migration `0007` ajoute `kind` aux DB existantes | 2 | | `src/api/__init__.py` (modif) | `init_api` initialise le schéma `api_tokens` au démarrage | 2 | | `src/mcp/auth.py` (créer) | Garde `before_request` sur `/_mcp` | 3 | | `src/app.py` (modif) | Exemption CSRF `/_mcp` + `init_mcp_auth` | 4 | | `src/api/auth.py` (modif) | `require_token` refuse `kind='mcp'` ; retire le `print` de debug | 5 | | `src/mcp/account.py` (créer) | Blueprint Flask : POST création / révocation de jetons | 6 | | `src/pages/compte/mcp.py` (créer) | Page Dash « Connecteur MCP » (liste, formulaire, instructions clients) | 7 | | `src/pages/_compte_shell.py` (modif) | Entrée de section « Connecteur MCP » | 7 | | `.template.env`, `CHANGELOG.md` (modif) | Doc config + changelog | 8 | | `tests/api/test_tokens_db.py` (modif) | Tests data layer | 1 | | `tests/api/test_migrations.py` (créer) | Test migration 0007 | 2 | | `tests/api/test_init_api_schema.py` (créer) | Test init schema au démarrage | 2 | | `tests/mcp/test_auth.py` (créer) | Tests du garde `/_mcp` | 3 | | `tests/mcp/test_app_wiring.py` (créer) | Test intégration app (CSRF + garde câblés) | 4 | | `tests/api/test_auth.py` (modif) | Tests refus `kind='mcp'` côté REST | 5 | | `tests/mcp/test_account_routes.py` (créer) | Tests blueprint création/révocation | 6 | | `tests/mcp/test_account_page.py` (créer) | Tests page compte | 7 | --- ### Task 1: Couche données `api_tokens` — colonne `kind` + fonctions par utilisateur **Files:** - Modify: `src/api/tokens_db.py` - Test: `tests/api/test_tokens_db.py` **Interfaces:** - Produces: - `create_token(db_path, label, user_id=None, kind='api') -> tuple[str, int]` - `list_user_tokens(db_path, user_id, kind='mcp') -> list[dict]` - `revoke_user_token(db_path, token_id, user_id) -> bool` - `api_tokens` a une colonne `kind TEXT NOT NULL DEFAULT 'api'` - [ ] **Step 1: Écrire les tests qui échouent** Ajouter à la fin de `tests/api/test_tokens_db.py` : ```python def test_create_token_defaults_to_api_kind(temp_db): token, token_id = tokens_db.create_token(temp_db, "x") row = tokens_db.get_token_by_plaintext(temp_db, token) assert row["kind"] == "api" def test_create_token_with_mcp_kind(temp_db): token, _ = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp") row = tokens_db.get_token_by_plaintext(temp_db, token) assert row["kind"] == "mcp" assert row["user_id"] == 7 def test_list_user_tokens_filters_by_user_and_kind(temp_db): tokens_db.create_token(temp_db, "mcp-u1", user_id=1, kind="mcp") tokens_db.create_token(temp_db, "api-u1", user_id=1, kind="api") tokens_db.create_token(temp_db, "mcp-u2", user_id=2, kind="mcp") rows = tokens_db.list_user_tokens(temp_db, 1, "mcp") assert [r["label"] for r in rows] == ["mcp-u1"] def test_revoke_user_token_revokes_own(temp_db): token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp") assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is not None def test_revoke_user_token_refuses_other_owner(temp_db): token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp") assert tokens_db.revoke_user_token(temp_db, token_id, 999) is False assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is None def test_revoke_user_token_already_revoked_returns_false(temp_db): _, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp") assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True assert tokens_db.revoke_user_token(temp_db, token_id, 1) is False ``` - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/api/test_tokens_db.py -v` Expected: FAIL (`kind` inconnu / `list_user_tokens`, `revoke_user_token` non définis). - [ ] **Step 3: Implémenter** Dans `src/api/tokens_db.py` : 1. Ajouter `kind` à `SCHEMA` (après `count_total`, avant `revoked_at` ou en fin — l'ordre importe peu) : ```python SCHEMA = """ CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, label TEXT NOT NULL, user_id INTEGER, created_at TEXT NOT NULL, last_used_at TEXT, count_total INTEGER NOT NULL DEFAULT 0, revoked_at TEXT, kind TEXT NOT NULL DEFAULT 'api' ); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); """ ``` 2. Remplacer `create_token` : ```python 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, kind, created_at) " "VALUES (?, ?, ?, ?, ?)", (_hash(token), label, user_id, kind, _utcnow_iso()), ) conn.commit() return token, cur.lastrowid ``` 3. Ajouter, après `list_tokens` : ```python 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 ``` - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/api/test_tokens_db.py -v` Expected: PASS (tous, dont les tests existants). - [ ] **Step 5: Commit** ```bash pre-commit run --files src/api/tokens_db.py tests/api/test_tokens_db.py git add src/api/tokens_db.py tests/api/test_tokens_db.py git commit -m "feat(mcp): colonne kind + fonctions jetons par utilisateur (scope B #111)" ``` --- ### Task 2: Migration `0007` + initialisation du schéma au démarrage **Files:** - Modify: `src/migrations.py` - Modify: `src/api/__init__.py` - Test: `tests/api/test_migrations.py` (créer), `tests/api/test_init_api_schema.py` (créer) **Interfaces:** - Consumes: `tokens_db.init_schema` (existant), `src.auth.db.get_conn` / `reset_conn_for_tests` (existants). - Produces: sur toute DB (fraîche ou existante), `api_tokens.kind` existe après démarrage. **Context:** Les jetons existants en prod n'ont pas `kind`. `init_api` (appelé ~L112 de `src/app.py`, AVANT `init_subscriptions`/`apply_pending` ~L129) doit garantir que `api_tokens` existe avant que la migration ne l'`ALTER`. La table `SCHEMA` d'une DB fraîche inclut déjà `kind` (Task 1) → l'`ALTER` lève alors _duplicate column name_, déjà toléré par `apply_pending()`. - [ ] **Step 1: Écrire les tests qui échouent** `tests/api/test_migrations.py` : ```python import sqlite3 def test_migration_0007_adds_kind_to_legacy_api_tokens(monkeypatch, tmp_path): from src import migrations from src.auth import db as auth_db from src.subscriptions import db as sub_db db_path = tmp_path / "users.test.sqlite" monkeypatch.setenv("USERS_DB_PATH", str(db_path)) auth_db.reset_conn_for_tests() auth_db.init_schema() sub_db.init_schema() conn = auth_db.get_conn() conn.execute("DROP TABLE IF EXISTS api_tokens") # ancienne table SANS colonne kind conn.execute( "CREATE TABLE api_tokens (" "id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, " "label TEXT NOT NULL, user_id INTEGER, created_at TEXT NOT NULL, " "last_used_at TEXT, count_total INTEGER NOT NULL DEFAULT 0, revoked_at TEXT)" ) conn.commit() migrations.apply_pending() cols = [r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()] assert "kind" in cols # idempotent : un second passage ne lève pas migrations.apply_pending() auth_db.reset_conn_for_tests() ``` `tests/api/test_init_api_schema.py` : ```python import sqlite3 def test_init_api_creates_api_tokens_table(monkeypatch, tmp_path): from flask import Flask from src.api import init_api, tracking db_path = tmp_path / "users.test.sqlite" monkeypatch.setenv("USERS_DB_PATH", str(db_path)) server = Flask(__name__) init_api(server) try: with sqlite3.connect(str(db_path)) as conn: rows = conn.execute( "SELECT name FROM sqlite_master " "WHERE type='table' AND name='api_tokens'" ).fetchall() finally: tracking.stop_worker() assert rows == [("api_tokens",)] ``` - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/api/test_migrations.py tests/api/test_init_api_schema.py -v` Expected: FAIL (`0007` absent → pas de `kind` ; `init_api` ne crée pas `api_tokens`). - [ ] **Step 3: Implémenter** Dans `src/migrations.py`, ajouter à la fin de la liste `_MIGRATIONS` (après `0006_create_admin_actions`) : ```python ( "0007_add_kind_to_api_tokens", "ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'", ), ``` Dans `src/api/__init__.py`, fonction `init_api`, ajouter l'initialisation du schéma des jetons. Le module importe déjà `os` (en fin de fonction) et `routes`. Modifier ainsi : ```python 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") # ... (config inchangée) ... api = Api(server) api.register_blueprint(routes.bp) tracking.start_worker(os.environ["USERS_DB_PATH"]) ``` (Retirer l'ancien bloc `import os` / `from src.api import tracking` en fin de fonction, désormais remontés.) - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/api/test_migrations.py tests/api/test_init_api_schema.py -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash pre-commit run --files src/migrations.py src/api/__init__.py tests/api/test_migrations.py tests/api/test_init_api_schema.py git add src/migrations.py src/api/__init__.py tests/api/test_migrations.py tests/api/test_init_api_schema.py git commit -m "feat(mcp): migration 0007 kind + init schema jetons au démarrage (scope B #111)" ``` --- ### Task 3: Garde `/_mcp` — `src/mcp/auth.py` **Files:** - Create: `src/mcp/auth.py` - Test: `tests/mcp/test_auth.py` (créer) **Interfaces:** - Consumes: `tokens_db.get_token_by_plaintext`, `tokens_db.increment_usage`, `has_active_subscription`, `TOUS_ABONNES`. - Produces: `init_mcp_auth(server: Flask) -> None` (enregistre un `before_request`). - [ ] **Step 1: Écrire les tests qui échouent** `tests/mcp/test_auth.py` : ```python import pytest from flask import Flask @pytest.fixture def mcp_app(monkeypatch, tmp_path): from src.api import tokens_db from src.auth import db as auth_db from src.mcp.auth import init_mcp_auth from src.subscriptions import db as sub_db db_path = tmp_path / "users.test.sqlite" monkeypatch.setenv("USERS_DB_PATH", str(db_path)) auth_db.reset_conn_for_tests() auth_db.init_schema() sub_db.init_schema() tokens_db.init_schema(db_path) app = Flask(__name__) @app.route("/_mcp", methods=["GET", "POST"]) def _mcp(): return "ok", 200 init_mcp_auth(app) yield app, db_path auth_db.reset_conn_for_tests() def _subscribed_uid(): from src.auth import db as auth_db from src.subscriptions import db as sub_db uid = auth_db.create_user("mcp@ex.fr", "hash") _, sub_id = sub_db.create_pending(uid, "colibre-1", "simple") sub_db.set_status(sub_id, "active") return uid def test_missing_header_401_with_challenge(mcp_app): app, _ = mcp_app resp = app.test_client().post("/_mcp") assert resp.status_code == 401 assert resp.headers["WWW-Authenticate"] == 'Bearer realm="colibre-mcp"' def test_empty_bearer_401(mcp_app): app, _ = mcp_app resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer "}) assert resp.status_code == 401 def test_unknown_token_401(mcp_app): app, _ = mcp_app resp = app.test_client().post( "/_mcp", headers={"Authorization": "Bearer colibre_unknown"} ) assert resp.status_code == 401 def test_revoked_mcp_token_401(mcp_app): from src.api import tokens_db app, db_path = mcp_app uid = _subscribed_uid() token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") tokens_db.revoke_user_token(db_path, tid, uid) resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 401 def test_api_kind_token_rejected_401(mcp_app): from src.api import tokens_db app, db_path = mcp_app uid = _subscribed_uid() token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="api") resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 401 def test_mcp_token_without_user_403(mcp_app): from src.api import tokens_db app, db_path = mcp_app token, _ = tokens_db.create_token(db_path, "x", user_id=None, kind="mcp") resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 403 def test_mcp_token_no_active_subscription_403(mcp_app): from src.api import tokens_db from src.auth import db as auth_db app, db_path = mcp_app uid = auth_db.create_user("nosub@ex.fr", "hash") # aucun abonnement token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 403 def test_mcp_token_active_subscription_passes_and_increments(mcp_app): from src.api import tokens_db app, db_path = mcp_app uid = _subscribed_uid() token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 200 assert tokens_db.get_token_by_plaintext(db_path, token)["count_total"] == 1 def test_tous_abonnes_bypasses_subscription(mcp_app, monkeypatch): from src.api import tokens_db from src.auth import db as auth_db monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", True) app, db_path = mcp_app uid = auth_db.create_user("nosub2@ex.fr", "hash") token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") resp = app.test_client().post( "/_mcp", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 200 ``` - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/mcp/test_auth.py -v` Expected: FAIL (`src.mcp.auth` n'existe pas). - [ ] **Step 3: Implémenter** `src/mcp/auth.py` : ```python 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 ``` - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/mcp/test_auth.py -v` Expected: PASS (10 tests). - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/auth.py tests/mcp/test_auth.py git add src/mcp/auth.py tests/mcp/test_auth.py git commit -m "feat(mcp): garde d'abonnement sur /_mcp (scope B #111)" ``` --- ### Task 4: Câblage dans `src/app.py` (exemption CSRF + `init_mcp_auth`) **Files:** - Modify: `src/app.py` - Test: `tests/mcp/test_app_wiring.py` (créer) **Interfaces:** - Consumes: `init_mcp_auth` (Task 3). **Context:** `/_mcp` reçoit des POST JSON-RPC externes sans jeton CSRF → doit être exempté comme `/_dash`/`/_reload`. Le garde n'est câblé que quand `DASH_MCP_ENABLED=true`. Le test importe l'app avec ce flag pour vérifier que POST `/_mcp` sans jeton renvoie **401** (garde) et non **400/403 CSRF** — preuve que l'exemption ET le garde sont câblés. - [ ] **Step 1: Écrire le test qui échoue** `tests/mcp/test_app_wiring.py` : ```python import importlib def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path): # DB éphémère + secrets requis par init_auth/init_subscriptions. monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite")) monkeypatch.setenv("SECRET_KEY", "test-secret-key") monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") monkeypatch.setenv("DASH_MCP_ENABLED", "true") from src.auth import db as auth_db auth_db.reset_conn_for_tests() import src.app as app_module app_module = importlib.reload(app_module) client = app_module.app.server.test_client() # Pas de jeton : le garde renvoie 401 (et NON une erreur CSRF 400/403), # ce qui prouve exemption CSRF + garde câblés sur /_mcp. resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) assert resp.status_code == 401 assert resp.headers.get("WWW-Authenticate") == 'Bearer realm="colibre-mcp"' auth_db.reset_conn_for_tests() ``` > Note implémenteur : si `importlib.reload(src.app)` pose problème d'effets de bord (double enregistrement de pages Dash), remplacer par un import direct protégé — mais l'app est conçue pour un import unique ; le `reload` isolé dans ce test suffit. Si le reload échoue pour une raison d'environnement (ex. `use_pages` duplication), documenter le blocage (NEEDS_CONTEXT) plutôt que de contourner en désactivant le garde. - [ ] **Step 2: Lancer le test, vérifier l'échec** Run: `uv run pytest tests/mcp/test_app_wiring.py -v` Expected: FAIL (sans câblage, POST `/_mcp` → 404/erreur CSRF, pas 401 avec le challenge). - [ ] **Step 3: Implémenter** Dans `src/app.py` : 1. Étendre la boucle d'exemption CSRF (actuellement `/_dash` / `/_reload`) : ```python if _auth_csrf is not None: for _rule in app.server.url_map.iter_rules(): if ( _rule.rule.startswith("/_dash") or _rule.rule.startswith("/_reload") or _rule.rule.startswith("/_mcp") ): _vf = app.server.view_functions.get(_rule.endpoint) if _vf is not None: _auth_csrf.exempt(_vf) ``` > Attention à l'ordre : les routes `/_mcp` ne sont enregistrées qu'après `configure_mcp_server(...)`. La boucle d'exemption actuelle s'exécute AVANT le bloc `if _mcp_enabled:`. **Déplacer l'appel à l'exemption CSRF après le bloc MCP**, ou exempter `/_mcp` dans une seconde passe. Solution retenue : dans le bloc `if _mcp_enabled:`, après `configure_mcp_server(...)` et l'import des tools, ré-itérer l'`url_map` pour exempter `/_mcp` puis appeler `init_mcp_auth`. 2. Modifier le bloc `if _mcp_enabled:` : ```python if _mcp_enabled: from dash.mcp import configure_mcp_server # noqa: E402 configure_mcp_server( include_layout=False, include_callbacks=False, include_pages=False, include_clientside_callbacks=False, ) 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) ``` (La boucle d'exemption existante en haut peut garder uniquement `/_dash` / `/_reload` — ne pas y ajouter `/_mcp` puisque les routes n'existent pas encore à ce point.) - [ ] **Step 4: Lancer le test, vérifier le succès** Run: `uv run pytest tests/mcp/test_app_wiring.py -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash pre-commit run --files src/app.py tests/mcp/test_app_wiring.py git add src/app.py tests/mcp/test_app_wiring.py git commit -m "feat(mcp): câble le garde /_mcp + exemption CSRF dans l'app (scope B #111)" ``` --- ### Task 5: L'API REST refuse les jetons `kind='mcp'` **Files:** - Modify: `src/api/auth.py` - Test: `tests/api/test_auth.py` **Context:** Jetons dédiés : un jeton `mcp` ne doit pas ouvrir `/api/v1`. Les jetons `api` (tous les jetons CLI existants) restent acceptés → comportement REST inchangé. On en profite pour retirer le `print(API_AUTH_DISABLED)` de debug (ligne 19). - [ ] **Step 1: Écrire les tests qui échouent** Ajouter à `tests/api/test_auth.py` : ```python def test_mcp_kind_token_rejected_by_rest_api(temp_db): token, _ = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp") app = _make_app() resp = app.test_client().get( "/protected", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 401 assert resp.get_json()["message"] == "invalid_token" def test_api_kind_token_accepted_by_rest_api(temp_db): token, _ = tokens_db.create_token(temp_db, "x", kind="api") app = _make_app() resp = app.test_client().get( "/protected", headers={"Authorization": f"Bearer {token}"} ) assert resp.status_code == 200 ``` - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/api/test_auth.py -v` Expected: FAIL (`test_mcp_kind_token_rejected_by_rest_api` : le jeton mcp est accepté → 200). - [ ] **Step 3: Implémenter** Dans `src/api/auth.py`, remplacer le corps de `wrapper` (retirer le `print`, ajouter le refus `mcp`) : ```python @wraps(fn) def wrapper(*args, **kwargs): if not API_AUTH_DISABLED: header = request.headers.get("Authorization", "") if not header.startswith("Bearer "): _abort_401("missing_token") token = header[len("Bearer ") :].strip() if not token: _abort_401("missing_token") db_path = os.environ["USERS_DB_PATH"] row = tokens_db.get_token_by_plaintext(db_path, token) if row is None: _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) return wrapper ``` - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/api/test_auth.py -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash pre-commit run --files src/api/auth.py tests/api/test_auth.py git add src/api/auth.py tests/api/test_auth.py git commit -m "feat(mcp): l'API REST refuse les jetons kind=mcp (scope B #111)" ``` --- ### Task 6: Blueprint de gestion des jetons — `src/mcp/account.py` **Files:** - Create: `src/mcp/account.py` - Test: `tests/mcp/test_account_routes.py` (créer) **Interfaces:** - Consumes: `tokens_db.create_token`, `tokens_db.revoke_user_token`, `current_user`, la vérif d'abonnement. - Produces: `mcp_account_bp` (Blueprint Flask) avec : - `POST /compte/mcp/creer` (champ form `label`) → crée un jeton `kind='mcp'`, stocke le clair dans `session["mcp_new_token"]`, redirige `/compte/mcp`. - `POST /compte/mcp/revoquer/` → `revoke_user_token`, redirige `/compte/mcp`. - Les deux exigent authentification + abonnement actif (sinon redirection). - [ ] **Step 1: Écrire les tests qui échouent** `tests/mcp/test_account_routes.py` : ```python import pytest from flask import Flask @pytest.fixture def account_client(monkeypatch, tmp_path): from src.api import tokens_db from src.auth import db as auth_db from src.auth.setup import init_auth from src.mcp.account import mcp_account_bp from src.subscriptions import db as sub_db monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite")) monkeypatch.setenv("SECRET_KEY", "test-secret-key") monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") auth_db.reset_conn_for_tests() app = Flask(__name__) app.config["WTF_CSRF_ENABLED"] = False init_auth(app) # login manager + CSRF (désactivé) + schéma auth sub_db.init_schema() db_path = tmp_path / "users.test.sqlite" tokens_db.init_schema(db_path) app.register_blueprint(mcp_account_bp) yield app, db_path auth_db.reset_conn_for_tests() def _login(app, uid): client = app.test_client() with client.session_transaction() as sess: sess["_user_id"] = str(uid) sess["_fresh"] = True return client def _subscribed_uid(): from src.auth import db as auth_db from src.subscriptions import db as sub_db uid = auth_db.create_user("sub@ex.fr", "hash") _, sub_id = sub_db.create_pending(uid, "colibre-1", "simple") sub_db.set_status(sub_id, "active") return uid def test_creer_generates_mcp_token_for_subscriber(account_client): from src.api import tokens_db app, db_path = account_client uid = _subscribed_uid() client = _login(app, uid) resp = client.post("/compte/mcp/creer", data={"label": "Claude portable"}) assert resp.status_code == 302 assert resp.headers["Location"].endswith("/compte/mcp") with client.session_transaction() as sess: assert sess.get("mcp_new_token", "").startswith("colibre_") rows = tokens_db.list_user_tokens(db_path, uid, "mcp") assert [r["label"] for r in rows] == ["Claude portable"] assert rows[0]["kind"] == "mcp" assert rows[0]["user_id"] == uid def test_revoquer_own_token(account_client): from src.api import tokens_db app, db_path = account_client uid = _subscribed_uid() client = _login(app, uid) token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") resp = client.post(f"/compte/mcp/revoquer/{tid}") assert resp.status_code == 302 assert tokens_db.get_token_by_plaintext(db_path, token)["revoked_at"] is not None def test_revoquer_other_users_token_is_noop(account_client): from src.api import tokens_db app, db_path = account_client uid = _subscribed_uid() client = _login(app, uid) other_token, other_id = tokens_db.create_token( db_path, "y", user_id=99999, kind="mcp" ) resp = client.post(f"/compte/mcp/revoquer/{other_id}") assert resp.status_code == 302 assert tokens_db.get_token_by_plaintext(db_path, other_token)["revoked_at"] is None def test_creer_blocked_without_subscription(account_client): from src.api import tokens_db from src.auth import db as auth_db app, db_path = account_client uid = auth_db.create_user("nosub@ex.fr", "hash") # pas d'abonnement client = _login(app, uid) resp = client.post("/compte/mcp/creer", data={"label": "x"}) assert resp.status_code == 302 assert resp.headers["Location"].endswith("/compte/abonnement") assert tokens_db.list_user_tokens(db_path, uid, "mcp") == [] def test_creer_blocked_when_anonymous(account_client): app, db_path = account_client resp = app.test_client().post("/compte/mcp/creer", data={"label": "x"}) assert resp.status_code == 302 assert "/connexion" in resp.headers["Location"] ``` - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/mcp/test_account_routes.py -v` Expected: FAIL (`src.mcp.account` n'existe pas). - [ ] **Step 3: Implémenter** `src/mcp/account.py` : ```python 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/", 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") ``` - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/mcp/test_account_routes.py -v` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/account.py tests/mcp/test_account_routes.py git add src/mcp/account.py tests/mcp/test_account_routes.py git commit -m "feat(mcp): blueprint création/révocation de jetons MCP (scope B #111)" ``` --- ### Task 7: Page compte « Connecteur MCP » + section + enregistrement blueprint **Files:** - Create: `src/pages/compte/mcp.py` - Modify: `src/pages/_compte_shell.py` (entrée SECTIONS) - Modify: `src/app.py` (enregistrer `mcp_account_bp`) - Test: `tests/mcp/test_account_page.py` (créer) **Interfaces:** - Consumes: `account_shell`, `account_guard`, `tokens_db.list_user_tokens`, `mcp_account_bp`. **Context:** Suit le patron des pages `compte/` (`register_page` + `layout()` gardé par `account_guard`, mutations via formulaires POST CSRF). Le jeton fraîchement créé est lu **une seule fois** depuis `session`. Les instructions clients couvrent Claude/Gemini/Mistral (jeton) et ChatGPT (caveat OAuth → B2). Si `DASH_MCP_ENABLED != "true"`, la page affiche un bandeau « bientôt disponible » et masque le formulaire (évite de proposer un connecteur inopérant en prod avant activation). - [ ] **Step 1: Écrire les tests qui échouent** `tests/mcp/test_account_page.py` : ```python def test_section_connecteur_mcp_present(): from src.pages._compte_shell import SECTIONS keys = {s["key"]: s for s in SECTIONS} assert "mcp" in keys assert keys["mcp"]["label"] == "Connecteur MCP" assert keys["mcp"]["href"] == "/compte/mcp" assert keys["mcp"]["require_subscription"] is True def test_page_module_registers_and_builds_client_instructions(): # importe l'app pour la découverte use_pages en contexte propre from src.app import app # noqa: F401 from src.pages.compte import mcp as mcp_page # helper pur : construit les 4 blocs d'instructions clients blocks = mcp_page.client_instructions("https://colibre.fr/_mcp", "colibre_TESTTOKEN") text = str(blocks) assert "colibre_TESTTOKEN" in text assert "https://colibre.fr/_mcp" in text for client_name in ("Claude", "Gemini", "Mistral", "ChatGPT"): assert client_name in text # caveat ChatGPT (OAuth / itération future) assert "OAuth" in text ``` > Note : `client_instructions(url, token)` est une fonction pure exposée par la page pour rester testable sans rendu Dash complet. `token` peut être un placeholder `` quand aucun jeton frais n'est disponible. - [ ] **Step 2: Lancer les tests, vérifier l'échec** Run: `uv run pytest tests/mcp/test_account_page.py -v` Expected: FAIL (section absente ; module page inexistant). - [ ] **Step 3: Implémenter** 1. Dans `src/pages/_compte_shell.py`, insérer l'entrée dans `SECTIONS` **avant** l'entrée `abonnement` : ```python { "key": "mcp", "label": "Connecteur MCP", "href": "/compte/mcp", "require_subscription": True, }, ``` 2. Créer `src/pages/compte/mcp.py` : ```python 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 = "" 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) ``` 3. Dans `src/app.py`, enregistrer le blueprint (toujours actif, après `init_subscriptions` par exemple — les routes POST sont inoffensives même MCP désactivé) : ```python from src.mcp.account import mcp_account_bp # noqa: E402 app.server.register_blueprint(mcp_account_bp) ``` - [ ] **Step 4: Lancer les tests, vérifier le succès** Run: `uv run pytest tests/mcp/test_account_page.py -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash pre-commit run --files src/pages/compte/mcp.py src/pages/_compte_shell.py src/app.py tests/mcp/test_account_page.py git add src/pages/compte/mcp.py src/pages/_compte_shell.py src/app.py tests/mcp/test_account_page.py git commit -m "feat(mcp): page compte Connecteur MCP + instructions clients (scope B #111)" ``` --- ### Task 8: Documentation config + CHANGELOG + suite complète **Files:** - Modify: `.template.env` - Modify: `CHANGELOG.md` - [ ] **Step 1: `.template.env`** — compléter la doc de `DASH_MCP_ENABLED` Localiser la ligne `DASH_MCP_ENABLED=false` et remplacer son commentaire par : ```bash # Active le serveur MCP (/_mcp) ET le connecteur d'abonné (scope B, #111). # À true, l'accès à /_mcp exige un jeton MCP (généré dans /compte/mcp) lié à un # abonnement actif. Laisser false tant que le connecteur n'est pas déployé. # Déploiement recommandé : activer d'abord sur test.colibre.fr (branche dev). DASH_MCP_ENABLED=false ``` - [ ] **Step 2: `CHANGELOG.md`** — ajouter sous `### 3.0.0 Fonctionnalités par abonnement` Ajouter, sous la même rubrique que l'entrée MCP de scope A : ```markdown - Connecteur MCP : les abonnés génèrent un jeton dans « Mon compte › Connecteur MCP » pour connecter leur agent IA (Claude, Gemini, Mistral) au serveur MCP colibre ; l'accès est conditionné à un abonnement actif ([#111](https://github.com/ColinMaudry/colibre/issues/111)). ``` (Adapter le style de puce/format à celui de l'entrée scope A voisine ; lire le fichier d'abord.) - [ ] **Step 3: Lancer la suite complète** Run: `uv run pytest` Expected: PASS (tout vert, hormis skips connus). Corriger toute régression avant de committer. - [ ] **Step 4: Commit** ```bash pre-commit run --files .template.env CHANGELOG.md git add .template.env CHANGELOG.md git commit -m "docs(mcp): config DASH_MCP_ENABLED + changelog connecteur (scope B #111)" ``` --- ## Self-Review (auteur du plan) **Couverture spec :** - Jeton dédié `kind` + réutilisation `api_tokens` → Task 1 ✅ - Migration + init schéma → Task 2 ✅ - Garde `/_mcp` (401/403, WWW-Authenticate, increment_usage, TOUS_ABONNES) → Task 3 ✅ - Câblage app + exemption CSRF → Task 4 ✅ - API REST refuse `mcp` → Task 5 ✅ - Génération/révocation self-service (anti-IDOR, affichage unique, sécurité serveur) → Tasks 6-7 ✅ - Page « Connecteur MCP », section compte gardée, instructions 4 clients + caveat ChatGPT → Task 7 ✅ - Config + changelog → Task 8 ✅ - Hors périmètre (OAuth/DCR/PKCE, rate-limiting, refonte REST) : non implémentés ✅ **Placeholders :** aucun TODO/TBD. `` (Task 7) est un placeholder d'affichage UI intentionnel, injecté avec le vrai jeton quand il vient d'être créé. **Cohérence des types :** `create_token(db_path, label, user_id=None, kind='api')`, `list_user_tokens(db_path, user_id, kind='mcp')`, `revoke_user_token(db_path, token_id, user_id) -> bool`, `init_mcp_auth(server)`, `mcp_account_bp`, `client_instructions(url, token)` — noms/paramètres identiques entre définition (Tasks 1/3/6/7) et usages. **Note ordre CSRF (Task 4) :** l'exemption `/_mcp` DOIT s'exécuter après `configure_mcp_server` (routes créées à ce moment) — capturé explicitement dans l'implémentation.