Files
colibre/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md
T
Colin Maudry 11cada2ffc docs: spec design tools MCP (scope A de #111)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:36:45 +02:00

229 lines
9.8 KiB
Markdown

# Design — Serveur MCP colibre, lot 1 : les tools (scope A de l'issue #111)
**Date** : 2026-07-09
**Issue** : #111 — Serveur MCP des données colibre, conditionné à l'abonnement
**Périmètre de CE design** : **A seulement** — exposer des fonctions métier comme
_tools_ MCP via le décorateur `@mcp_enabled` de Dash. **Sans authentification.**
La couche d'autorisation OAuth 2.0 + gate abonnement fera l'objet d'un design
séparé (scope B).
## Contexte
- La migration Dash 4.x (#101) est terminée (Dash 4.4). Le serveur MCP de Dash est
disponible à partir de Dash 4.3.0 → prérequis satisfait.
- Dash fournit la couche protocole MCP (`enable_mcp=True`, décorateur
`@mcp_enabled`, `configure_mcp_server(...)`). Dash **n'implémente pas**
l'authentification (cf. `/dash-mcp/auth`) → c'est le scope B, hors de ce design.
- Les **DECP sont des données publiques ouvertes**. Exposer recherche/stats en MCP
n'est donc pas une fuite de confidentialité ; le gate abonnement (scope B) relève
du contrôle d'accès / monétisation, pas du secret. Conséquence : le scope A peut
tourner en dev/local sans risque de données.
## Ce qui existe déjà et qu'on réutilise
- `src/db.py` : `query_marches(where_sql, params, columns, order_by, limit, offset)`,
`count_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, group_by, order_by, limit, offset)` — accès DuckDB paramétré renvoyant du
Polars.
- `src/api/filters.py` : `build_where(args, schema) -> (where_sql, params, order_by)`
et `parse_aggregators(...)` — moteur de filtres `col__op=valeur` déjà utilisé par
l'API REST (`src/api/routes.py`). **On le réutilise tel quel** pour que MCP et REST
partagent la même sémantique de filtrage.
- `src/utils/search.py` : `search_org(dff, query, org_type)` — recherche floue par
nom sur les acheteurs/titulaires (déjà utilisée par la page `/`).
- `src/utils/tracking.py` : `track_search(query, category)` — envoi direct à l'API
HTTP de tracking Matomo (`matomo.php`).
## Approches considérées
- **A — Module MCP fin réutilisant la couche données existante (RETENU).**
Nouveau package `src/mcp/`, 4 fonctions `@mcp_enabled` appelant directement
`db.*` / `filters.build_where` / `search_org`. Le « service layer » partagé
qu'on voudrait existe déjà (`src/db.py` + `src/api/filters.py`) → peu de code neuf.
- **B — Extraire un service commun** partagé entre `api/routes.py` et MCP. Meilleure
déduplication à terme mais gros refactor de l'API REST pour un gain marginal (la
logique est déjà factorisée). Rejeté (YAGNI).
- **C — MCP appelle l'API REST en HTTP.** Ajoute un saut HTTP interne en process,
perd le typage. Rejeté.
## Architecture
### Arborescence
```
src/mcp/
__init__.py
tools.py # les 4 fonctions @mcp_enabled (surface MCP)
serialization.py # Polars → JSON propre (dates ISO, montants, None-safe)
stats.py # helpers d'agrégation acheteur/titulaire, partagés par les 2 tools stats_*
```
### Activation (dans `src/app.py`, là où `Dash(...)` est construit)
```python
from dash.mcp import configure_mcp_server
app = Dash(__name__, ..., enable_mcp=os.getenv("DASH_MCP_ENABLED") == "true")
configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_pages=False,
include_clientside_callbacks=False,
) # n'expose QUE les fonctions @mcp_enabled — aucun callback/layout/page d'UI
import src.mcp.tools # noqa: E402,F401 — l'import enregistre les @mcp_enabled
```
**Sécurité (point de vigilance #111)** : en coupant `include_callbacks/layout/pages/ clientside`, aucun callback d'UI ni nom interne (type `get_data_from_s3`) n'est
exposé. La surface se limite aux 4 tools nommés proprement, avec docstrings
maîtrisées.
### Isolation des unités
- `tools.py` : **uniquement** la surface MCP (signatures, docstrings destinées à
l'agent, validation des arguments, appels aux helpers). Ne contient pas de SQL.
- `stats.py` : logique d'agrégation acheteur/titulaire, testable sans MCP.
- `serialization.py` : conversion Polars → structures JSON-sérialisables, testable
isolément.
## Les 4 tools
Tous renvoient des structures JSON-sérialisables (dict / list). Montants en euros
(float ou int), dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `null`.
Les docstrings sont exposées à l'agent (`expose_docstring=True`) : ce sont elles qui
documentent l'outil côté client.
### 1. `rechercher_organisations(query: str, type: str = "acheteur", limite: int = 20)`
- `type``{"acheteur", "titulaire"}`.
- Réutilise `search_org` sur la frame correspondante (mêmes données que la page `/`).
- Sortie : `[{ "id", "nom", "departement", "commune" }]`, triée par pertinence,
tronquée à `limite`.
- Rôle : **résoudre un nom → id** pour alimenter `stats_acheteur` / `stats_titulaire`.
### 2. `stats_acheteur(acheteur_id: str)`
Réutilise `aggregate_marches` avec un `where` filtrant sur `acheteur_id`.
Sortie :
```json
{
"identite": { "id", "nom", "departement", "commune" },
"nb_marches": 0,
"montant_total": 0,
"repartition_annuelle": [ { "annee", "nb_marches", "montant_total" } ],
"top_titulaires": [ { "id", "nom", "nb_marches", "montant_total" } ],
"top_cpv": [ { "cpv", "libelle", "nb_marches" } ]
}
```
- `top_*` limités (ex. 10). Si `acheteur_id` inconnu → `nb_marches: 0` et listes vides
(pas d'erreur).
### 3. `stats_titulaire(titulaire_id: str)`
Symétrique de `stats_acheteur` : `top_acheteurs` au lieu de `top_titulaires`,
montants remportés.
### 4. `rechercher_marches(...)` — signature **hybride**
```python
rechercher_marches(
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None, # ISO YYYY-MM-DD (dateNotification)
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None, # échappatoire moteur générique
)
```
- Les paramètres nommés sont traduits en tuples `col__op` (ex.
`montant_min``("montant__greater", ...)`, `objet_contient`
`("objet__contains", ...)`, `date_min``("dateNotification__greater", ...)`).
- `departement` mappe sur **`acheteur_departement_code`** (intention de requête la
plus courante). Pour filtrer sur le département du titulaire ou du lieu
d'exécution, l'agent passe par `filtres_avances`.
- `filtres_avances` : dict `{"col__op": valeur}` passant au moteur générique complet,
**fusionné** avec les paramètres nommés. Couvre toute colonne/opérateur supportés
par l'API REST.
- L'ensemble passe à `filters.build_where(args, duckdb_schema)` puis
`db.query_marches` / `db.count_marches`**même sémantique que l'API REST**.
- Pagination : `page_size` **fixe** (ex. 50), pagination par `page` (offset calculé).
- Sortie :
```json
{
"meta": { "page": 1, "page_size": 50, "total": 0 },
"marches": [
{
/* colonnes principales du marché */
}
]
}
```
- Erreurs de filtre (`FilterError`) → message d'erreur clair renvoyé à l'agent (pas
d'exception brute).
## Tracking Matomo des appels MCP
Nouveau helper dédié dans `src/utils/tracking.py` (on **ne** surcharge **pas**
`track_search`, qui gate sur `len(query) >= 4` et attend une requête texte) :
```python
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
...
```
- Même pattern que `track_search` : n'émet que si `not DEVELOPMENT` **et**
`MATOMO_DOMAIN` défini. Best-effort (ne doit jamais faire échouer l'appel du tool).
- Paramètres envoyés à `matomo.php` :
- `action_name = "MCP"` (hiérarchie `f"MCP / {tool_name}"` acceptable pour un arbre
lisible dans le rapport Actions),
- `dimension1 = tool_name`,
- `search` / `search_cat` en plus quand l'outil a une requête texte
(`rechercher_organisations`, `rechercher_marches`).
- Chaque tool appelle `track_mcp_tool(...)` en début d'exécution.
- **Prérequis de déploiement Matomo** : créer un _Custom Dimension_ slot 1, scope
**Action**, côté admin Matomo. Sinon `dimension1` est ignoré silencieusement.
## Déploiement / gating
- Activation via variable d'environnement `DASH_MCP_ENABLED` (Dash lit nativement
cette variable ; on la reflète dans le constructeur).
- **Off par défaut.** Activé en dev/local uniquement.
- **Pas activé en prod tant que le scope B (OAuth + gate abonnement) n'est pas
livré** — sinon le serveur MCP serait ouvert sans contrôle d'accès (feature
payante + coût compute).
- Documenter la variable dans `.template.env`.
## Tests
- Tests unitaires sur les 4 fonctions (données `tests/test.parquet`) :
- `rechercher_organisations` : résultats non vides, tri, `limite`, `type` invalide.
- `stats_acheteur` / `stats_titulaire` : forme de sortie, id inconnu → vides,
troncature des `top_*`.
- `rechercher_marches` : fusion params nommés ↔ `filtres_avances`, pagination
(`meta.total`, `page`), `FilterError` → message propre.
- `serialization` : dates ISO, `null`, montants.
- Smoke test : après import de `src.mcp.tools`, le registre MCP contient bien les
4 tools attendus (pas de test du protocole MCP de bout en bout, qui nécessiterait
un client MCP).
- `tests/test.parquet` étant réduit, vérifier que les colonnes utilisées (cpv,
montant, dateNotification, acheteur_id, titulaire_id, departement) y sont
présentes ; sinon compléter la fixture ou marquer les cas concernés.
## Hors périmètre (→ scope B, design séparé)
- Serveur d'autorisation OAuth 2.0 conforme à la spec MCP (2025-06-18) :
metadata protected-resource, PKCE, dynamic client registration.
- Branchement du gate `subscriptions.has_active_subscription(user_id)` sur
l'autorisation MCP.
- Documentation de connexion côté client (`claude mcp add …`).