Files
colibre/docs/superpowers/plans/2026-07-10-mcp-connecteur-scope-b.md
T

1338 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<int:token_id>``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/<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")
```
- [ ] **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 `<VOTRE_JETON>` 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 = "<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)
```
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. `<VOTRE_JETON>` (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.