Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.8 KiB
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)etparse_aggregators(...)— moteur de filtrescol__op=valeurdé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_enabledappelant directementdb.*/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.pyet 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)
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_orgsur 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 :
{
"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). Siacheteur_idinconnu →nb_marches: 0et 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
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", ...)). departementmappe suracheteur_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 parfiltres_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)puisdb.query_marches/db.count_marches→ même sémantique que l'API REST. - Pagination :
page_sizefixe (ex. 50), pagination parpage(offset calculé). - Sortie :
{
"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) :
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
...
- Même pattern que
track_search: n'émet que sinot DEVELOPMENTetMATOMO_DOMAINdéfini. Best-effort (ne doit jamais faire échouer l'appel du tool). - Paramètres envoyés à
matomo.php:action_name = "MCP"(hiérarchief"MCP / {tool_name}"acceptable pour un arbre lisible dans le rapport Actions),dimension1 = tool_name,search/search_caten 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
dimension1est 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,typeinvalide.stats_acheteur/stats_titulaire: forme de sortie, id inconnu → vides, troncature destop_*.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 …).