diff --git a/.template.env b/.template.env index 3191ca6..d73bdf9 100644 --- a/.template.env +++ b/.template.env @@ -72,3 +72,9 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks # Accès gratuit temporaire TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte + +# Serveur MCP (issue #111). Laisser à false tant que l'authentification +# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP +# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels : +# créer un Custom Dimension slot 1 (scope Action). +DASH_MCP_ENABLED=false diff --git a/CHANGELOG.md b/CHANGELOG.md index e046bf7..f3bdd43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90)) - Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95)) - Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94)) +- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111)) **Autres améliorations** diff --git a/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md b/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md new file mode 100644 index 0000000..b2b7554 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md @@ -0,0 +1,1033 @@ +# Tools MCP colibre (scope A de #111) — Implementation Plan + +> **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:** Exposer 4 fonctions métier des DECP comme _tools_ MCP via le décorateur `@mcp_enabled` de Dash, sans authentification (le gate abonnement = scope B, séparé). + +**Architecture:** Un package `src/mcp/` fin. `tools.py` contient les 4 fonctions décorées (surface MCP + tracking, aucun SQL) ; `queries.py` porte la logique données en réutilisant la couche existante (`src/db.py`, `src/api/filters.py`, `src/utils/search.py`) ; `serialization.py` convertit le Polars en JSON propre. Le serveur MCP est activé dans `src/app.py` via `enable_mcp` + `configure_mcp_server` qui coupe l'exposition des callbacks/layout/pages : seules les 4 fonctions sont exposées. + +**Tech Stack:** Python 3, Dash 4.4 (`dash.mcp`), Polars, DuckDB, httpx (tracking Matomo), pytest. + +## Global Constraints + +- Imports internes toujours préfixés `src.` (ex. `from src.db import ...`), jamais `db` ou `utils`. +- Périmètre = **scope A uniquement**. Aucune authentification, aucun gate abonnement dans ce plan. +- Sorties des tools = structures **JSON-sérialisables** : dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `None`, montants numériques. +- Le serveur MCP n'expose **que** les fonctions `@mcp_enabled` (couper `include_callbacks/layout/pages/clientside`). +- Activation par variable d'env `DASH_MCP_ENABLED` (`"true"` pour activer), **off par défaut**. Ne pas activer en prod tant que le scope B n'est pas livré. +- Colonnes réelles de la table `decp` : `uid`, `objet`, `montant`, `dateNotification`, `codeCPV`, `acheteur_id`, `acheteur_nom`, `acheteur_departement_code`, `acheteur_departement_nom`, `acheteur_commune_nom`, `titulaire_id`, `titulaire_nom`, `titulaire_departement_nom`, `titulaire_commune_nom`. **Pas de colonne `annee`** → dériver l'année via `date_part('year', "dateNotification")`. **Pas de libellé CPV** dans les données. +- Avant chaque commit, le hook `pre-commit` (ruff + prettier) s'exécute et peut reformater : si des fichiers sont modifiés par le hook, refaire `git add` puis `git commit`. +- Lancer les tests avec `uv run pytest`. Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest` sans chemin) uniquement à la dernière tâche. + +## File Structure + +- Create: `src/mcp/__init__.py` — package vide. +- Create: `src/mcp/serialization.py` — `to_json_records(df) -> list[dict]`. +- Create: `src/mcp/queries.py` — `search_organisations`, `search_marches`, `build_where_args`, `compute_org_stats`, `_org_identite`. +- Create: `src/mcp/tools.py` — les 4 fonctions `@mcp_enabled`. +- Modify: `src/utils/tracking.py` — ajouter `track_mcp_tool`. +- Modify: `src/utils/search.py` — ajouter le paramètre `track: bool = True` à `search_org`. +- Modify: `src/app.py` — activer le serveur MCP (constructeur + `configure_mcp_server` + import des tools). +- Modify: `.template.env` — documenter `DASH_MCP_ENABLED`. +- Create: `tests/mcp/__init__.py`, `tests/mcp/test_serialization.py`, `tests/mcp/test_tracking.py`, `tests/mcp/test_queries.py`, `tests/mcp/test_tools.py`. + +Le harness de test racine (`tests/conftest.py`) écrit `tests/test.parquet` (1 ligne : `acheteur_id="123"`, `acheteur_nom="ACHETEUR 1"`, `titulaire_id="345"`, `titulaire_nom="TITULAIRE 1"`, `montant=10`, `codeCPV="71600000"`, `dateNotification=2025-01-01`, `acheteur_departement_code="75"`, `objet="Objet test"`) et construit une base DuckDB de test isolée à l'import du conftest. Les tests sous `tests/mcp/` en héritent automatiquement. + +--- + +### Task 1: `serialization.py` — conversion Polars → JSON + +**Files:** + +- Create: `src/mcp/__init__.py` +- Create: `src/mcp/serialization.py` +- Test: `tests/mcp/__init__.py`, `tests/mcp/test_serialization.py` + +**Interfaces:** + +- Produces: `to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]` — convertit chaque ligne en dict JSON-sérialisable ; `datetime.date`/`datetime.datetime` → chaîne ISO, `None` préservé. + +- [ ] **Step 1: Créer le package et le fichier de test** + +Créer `src/mcp/__init__.py` (vide) et `tests/mcp/__init__.py` (vide), puis écrire le test : + +```python +# tests/mcp/test_serialization.py +import datetime + +import polars as pl + +from src.mcp.serialization import to_json_records + + +def test_dates_become_iso_strings(): + df = pl.DataFrame({"d": [datetime.date(2025, 1, 1)], "n": [10]}) + assert to_json_records(df) == [{"d": "2025-01-01", "n": 10}] + + +def test_none_is_preserved(): + df = pl.DataFrame({"nom": [None], "x": [3]}) + assert to_json_records(df) == [{"nom": None, "x": 3}] + + +def test_strings_and_numbers_untouched(): + df = pl.DataFrame({"s": ["abc"], "f": [1.5]}) + assert to_json_records(df) == [{"s": "abc", "f": 1.5}] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_serialization.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.serialization'` + +- [ ] **Step 3: Implémenter `serialization.py`** + +```python +# src/mcp/serialization.py +import datetime +from typing import Any + +import polars as pl + + +def to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]: + """Convertit un DataFrame Polars en liste de dicts JSON-sérialisables. + + Les dates/datetimes deviennent des chaînes ISO 8601 ; les valeurs nulles + restent None. Utilisé par tous les tools MCP pour produire une sortie propre. + """ + return [ + {key: _jsonify(value) for key, value in row.items()} + for row in df.to_dicts() + ] + + +def _jsonify(value: Any) -> Any: + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_serialization.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/__init__.py src/mcp/serialization.py tests/mcp/__init__.py tests/mcp/test_serialization.py +git commit -m "feat(mcp): sérialisation Polars -> JSON pour les tools MCP" +``` + +(Si le hook reformate, refaire `git add` puis `git commit`.) + +--- + +### Task 2: `track_mcp_tool` — tracking Matomo des appels MCP + +**Files:** + +- Modify: `src/utils/tracking.py` +- Test: `tests/mcp/test_tracking.py` + +**Interfaces:** + +- Consumes: le module `src.utils.tracking` (constante `DEVELOPMENT`, `post` de httpx). +- Produces: `track_mcp_tool(tool_name: str, query: str | None = None) -> None` — best-effort, n'émet qu'en prod (`not DEVELOPMENT` et `MATOMO_DOMAIN` défini) ; envoie `action_name="MCP / "`, `dimension1=`, et `search=` si fourni. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_tracking.py +import src.utils.tracking as tracking + + +def test_track_mcp_tool_sends_action_and_dimension(monkeypatch): + captured = {} + + def fake_post(url, params): + captured["url"] = url + captured["params"] = params + + class _R: + def raise_for_status(self): + pass + + return _R() + + monkeypatch.setattr(tracking, "DEVELOPMENT", False) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + monkeypatch.setenv("MATOMO_ID_SITE", "1") + + tracking.track_mcp_tool("rechercher_marches", query="informatique") + + assert captured["params"]["action_name"] == "MCP / rechercher_marches" + assert captured["params"]["dimension1"] == "rechercher_marches" + assert captured["params"]["search"] == "informatique" + + +def test_track_mcp_tool_noop_in_development(monkeypatch): + called = False + + def fake_post(url, params): + nonlocal called + called = True + + monkeypatch.setattr(tracking, "DEVELOPMENT", True) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + + tracking.track_mcp_tool("stats_acheteur") + + assert called is False +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_tracking.py -v` +Expected: FAIL — `AttributeError: module 'src.utils.tracking' has no attribute 'track_mcp_tool'` + +- [ ] **Step 3: Ajouter `track_mcp_tool` à `src/utils/tracking.py`** + +Ajouter à la fin du fichier (les imports `os`, `uuid`, `localtime`, `post`, `DEVELOPMENT` sont déjà présents en tête) : + +```python +def track_mcp_tool(tool_name: str, query: str | None = None) -> None: + """Enregistre un appel d'outil MCP dans Matomo (best-effort, prod uniquement). + + `action_name="MCP / "`, `dimension1=`. Si l'outil porte une + requête texte, elle est envoyée en `search`. Nécessite un Custom Dimension + slot 1 (scope Action) configuré côté Matomo — sinon `dimension1` est ignoré. + Ne lève jamais : une panne Matomo ne doit pas casser l'appel du tool. + """ + if DEVELOPMENT or not os.getenv("MATOMO_DOMAIN"): + return + params = { + "idsite": os.getenv("MATOMO_ID_SITE"), + "url": "https://colibre.fr/_mcp", + "rec": "1", + "action_name": f"MCP / {tool_name}", + "dimension1": tool_name, + "rand": uuid.uuid4().hex, + "apiv": "1", + "h": localtime().tm_hour, + "m": localtime().tm_min, + "s": localtime().tm_sec, + "token_auth": os.getenv("MATOMO_TOKEN"), + } + if query: + params["search"] = query + params["search_cat"] = "mcp" + try: + post(url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params) + except Exception: + pass +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_tracking.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/tracking.py tests/mcp/test_tracking.py +git commit -m "feat(mcp): helper track_mcp_tool pour tracer les appels MCP dans Matomo" +``` + +--- + +### Task 3: `search_org` — paramètre `track` optionnel + +**Files:** + +- Modify: `src/utils/search.py` +- Test: `tests/mcp/test_queries.py` (créé ici, complété aux tâches suivantes) + +**Interfaces:** + +- Consumes: `search_org(dff, query, org_type)` existant. +- Produces: `search_org(dff, query, org_type, track: bool = True)` — quand `track=False`, ne déclenche pas `track_search(...)`. Comportement inchangé par défaut. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_queries.py +import src.utils.search as search_mod +from src.utils.data import DF_ACHETEURS +from src.utils.search import search_org + + +def test_search_org_track_false_skips_track_search(monkeypatch): + calls = [] + monkeypatch.setattr( + search_mod, "track_search", lambda q, c: calls.append((q, c)) + ) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=False) + + assert calls == [] + + +def test_search_org_track_true_calls_track_search(monkeypatch): + calls = [] + monkeypatch.setattr( + search_mod, "track_search", lambda q, c: calls.append((q, c)) + ) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True) + + assert calls == [("ACHETEUR", "home_page_search")] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `TypeError: search_org() got an unexpected keyword argument 'track'` + +- [ ] **Step 3: Ajouter le paramètre `track`** + +Dans `src/utils/search.py`, modifier la signature et le corps : + +```python +def search_org( + dff: pl.DataFrame, query: str, org_type: str, track: bool = True +) -> pl.DataFrame: +``` + +et remplacer : + +```python + # Enregistrement des recherche dans Matomo + track_search(query, "home_page_search") +``` + +par : + +```python + # Enregistrement des recherche dans Matomo + if track: + track_search(query, "home_page_search") +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/search.py tests/mcp/test_queries.py +git commit -m "feat(mcp): search_org accepte track=False pour ne pas polluer Matomo" +``` + +--- + +### Task 4: `queries.py` — `search_organisations` + +**Files:** + +- Create: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `search_org(dff, query, org_type, track=False)` (Task 3), `DF_ACHETEURS`/`DF_TITULAIRES` de `src.utils.data`, `to_json_records` (Task 1). +- Produces: `search_organisations(query: str, org_type: str = "acheteur", limite: int = 20) -> list[dict]` — retourne `[{"id", "nom", "departement"}]`. Lève `ValueError` si `org_type` invalide. Aussi : constantes `PAGE_SIZE = 50`, `TOP_N = 10`, `ORG_FRAMES`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +import pytest + +from src.mcp.queries import search_organisations + + +def test_search_organisations_finds_known_acheteur(): + result = search_organisations("ACHETEUR", "acheteur") + assert any(r["id"] == "123" for r in result) + first = next(r for r in result if r["id"] == "123") + assert set(first.keys()) == {"id", "nom", "departement"} + + +def test_search_organisations_invalid_type_raises(): + with pytest.raises(ValueError): + search_organisations("x", "autre") + + +def test_search_organisations_respects_limite(): + result = search_organisations("ACHETEUR", "acheteur", limite=1) + assert len(result) <= 1 +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.queries'` + +- [ ] **Step 3: Créer `src/mcp/queries.py` avec `search_organisations`** + +```python +# src/mcp/queries.py +from src.mcp.serialization import to_json_records # noqa: F401 (utilisé aux tâches suivantes) +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org + +PAGE_SIZE = 50 +TOP_N = 10 +ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} + + +def search_organisations( + query: str, org_type: str = "acheteur", limite: int = 20 +) -> list[dict]: + """Recherche des organisations par nom, résout un nom -> id.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + df = search_org(ORG_FRAMES[org_type], query, org_type, track=False).head(limite) + return [ + { + "id": r[f"{org_type}_id"], + "nom": r[f"{org_type}_nom"], + "departement": r.get("Département"), + } + for r in df.to_dicts() + ] +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (5 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tool search_organisations (résolution nom -> id)" +``` + +--- + +### Task 5: `queries.py` — `build_where_args` + `search_marches` + +**Files:** + +- Modify: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `build_where(args, schema)` et `FilterError` de `src.api.filters` ; `query_marches`, `count_marches`, `schema` de `src.db` ; `to_json_records` (Task 1) ; `PAGE_SIZE` (Task 4). +- Produces: + + - `build_where_args(named: dict, filtres_avances: dict | None) -> list[tuple[str, str]]` — traduit paramètres nommés + filtres avancés en tuples `("col__op", "valeur")`. + - `search_marches(*, acheteur_id=None, titulaire_id=None, cpv=None, objet_contient=None, montant_min=None, montant_max=None, date_min=None, date_max=None, departement=None, page=1, filtres_avances=None) -> dict` — retourne `{"meta": {"page", "page_size", "total"}, "marches": [...]}` ou `{"error": ..., "champ": ...}` sur `FilterError`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +from src.mcp.queries import build_where_args, search_marches + + +def test_build_where_args_named_params(): + args = build_where_args( + {"acheteur_id": "123", "montant_min": 5, "objet_contient": "test"}, None + ) + assert ("acheteur_id__exact", "123") in args + assert ("montant__greater", "5") in args + assert ("objet__contains", "test") in args + + +def test_build_where_args_merges_filtres_avances(): + args = build_where_args( + {"acheteur_id": "123"}, {"titulaire_departement_code__exact": "35"} + ) + assert ("acheteur_id__exact", "123") in args + assert ("titulaire_departement_code__exact", "35") in args + + +def test_search_marches_returns_meta_and_rows(): + result = search_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + assert result["meta"]["page"] == 1 + assert result["meta"]["page_size"] == 50 + assert any(m["acheteur_id"] == "123" for m in result["marches"]) + # dates sérialisées en ISO + assert result["marches"][0]["dateNotification"] == "2025-01-01" + + +def test_search_marches_no_match_is_empty(): + result = search_marches(acheteur_id="inconnu-xyz") + assert result["meta"]["total"] == 0 + assert result["marches"] == [] + + +def test_search_marches_bad_filter_returns_error(): + result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) + assert "error" in result +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ImportError: cannot import name 'build_where_args'` + +- [ ] **Step 3: Étendre `src/mcp/queries.py`** + +Remplacer la ligne d'import `to_json_records` (marquée `noqa`) par les imports réels et ajouter le code. Nouvel en-tête d'imports en haut du fichier : + +```python +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches +from src.db import schema as duckdb_schema +from src.mcp.serialization import to_json_records +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org +``` + +Ajouter après les constantes : + +```python +# Colonnes renvoyées par rechercher_marches (sortie ciblée, pas SELECT *). +MARCHES_COLUMNS = [ + "uid", + "objet", + "montant", + "dateNotification", + "codeCPV", + "acheteur_id", + "acheteur_nom", + "acheteur_departement_code", + "titulaire_id", + "titulaire_nom", +] + +# (param nommé, colonne decp, opérateur du moteur de filtres API). +# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =. +_NAMED_FILTERS = [ + ("acheteur_id", "acheteur_id", "exact"), + ("titulaire_id", "titulaire_id", "exact"), + ("cpv", "codeCPV", "contains"), + ("objet_contient", "objet", "contains"), + ("montant_min", "montant", "greater"), + ("montant_max", "montant", "less"), + ("date_min", "dateNotification", "greater"), + ("date_max", "dateNotification", "less"), + ("departement", "acheteur_departement_code", "exact"), +] + + +def build_where_args( + named: dict, filtres_avances: dict | None +) -> list[tuple[str, str]]: + """Traduit les paramètres nommés + filtres avancés en tuples (col__op, valeur).""" + args: list[tuple[str, str]] = [] + for param, col, op in _NAMED_FILTERS: + value = named.get(param) + if value is not None: + args.append((f"{col}__{op}", str(value))) + if filtres_avances: + for key, value in filtres_avances.items(): + args.append((key, str(value))) + return args + + +def search_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, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés. Même sémantique de filtres que l'API REST.""" + named = { + "acheteur_id": acheteur_id, + "titulaire_id": titulaire_id, + "cpv": cpv, + "objet_contient": objet_contient, + "montant_min": montant_min, + "montant_max": montant_max, + "date_min": date_min, + "date_max": date_max, + "departement": departement, + } + args = build_where_args(named, filtres_avances) + try: + where_sql, params, order_sql = build_where(args, duckdb_schema) + except FilterError as e: + return {"error": str(e), "champ": e.field} + + page = max(1, int(page)) + offset = (page - 1) * PAGE_SIZE + order_by = order_sql or '"dateNotification" DESC, "uid" DESC' + df = query_marches( + where_sql, + params, + columns=MARCHES_COLUMNS, + order_by=order_by, + limit=PAGE_SIZE, + offset=offset, + ) + total = count_marches(where_sql, params) + return { + "meta": {"page": page, "page_size": PAGE_SIZE, "total": total}, + "marches": to_json_records(df), + } +``` + +Supprimer l'ancienne ligne `from src.mcp.serialization import to_json_records # noqa: F401` et l'ancien bloc d'imports partiel de la Task 4 (remplacés par l'en-tête ci-dessus). + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (10 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tool rechercher_marches (filtres hybrides + pagination)" +``` + +--- + +### Task 6: `queries.py` — `compute_org_stats` + +**Files:** + +- Modify: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `aggregate_marches`, `query_marches` de `src.db` ; `to_json_records` (Task 1) ; `TOP_N` (Task 4). +- Produces: `compute_org_stats(org_type: str, org_id: str) -> dict` — retourne `{"identite", "nb_marches", "montant_total", "repartition_annuelle", "top_s", "top_cpv"}`. Contrepartie = `titulaire` si `org_type="acheteur"`, sinon `acheteur`. `nb_marches=0` et listes vides si id inconnu (pas d'exception). Aussi `_org_identite(org_type, org_id) -> dict`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +from src.mcp.queries import compute_org_stats + + +def test_compute_org_stats_acheteur_known(): + stats = compute_org_stats("acheteur", "123") + assert stats["nb_marches"] >= 1 + assert stats["montant_total"] == 10 + assert stats["identite"]["id"] == "123" + assert stats["identite"]["nom"] == "ACHETEUR 1" + assert "top_titulaires" in stats + assert "top_cpv" in stats + # répartition annuelle dérivée de dateNotification (2025) + annees = [row["annee"] for row in stats["repartition_annuelle"]] + assert 2025 in annees + + +def test_compute_org_stats_titulaire_known(): + stats = compute_org_stats("titulaire", "345") + assert stats["nb_marches"] >= 1 + assert "top_acheteurs" in stats + + +def test_compute_org_stats_unknown_is_empty(): + stats = compute_org_stats("acheteur", "inconnu-xyz") + assert stats["nb_marches"] == 0 + assert stats["montant_total"] == 0 + assert stats["repartition_annuelle"] == [] + assert stats["top_titulaires"] == [] + assert stats["top_cpv"] == [] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ImportError: cannot import name 'compute_org_stats'` + +- [ ] **Step 3: Étendre `src/mcp/queries.py`** + +Ajouter `aggregate_marches` à l'import de `src.db` : + +```python +from src.db import aggregate_marches, count_marches, query_marches +``` + +Ajouter les fonctions : + +```python +def _org_identite(org_type: str, org_id: str) -> dict: + df = query_marches( + where_sql=f'"{org_type}_id" = ?', + params=[org_id], + columns=[ + f"{org_type}_id", + f"{org_type}_nom", + f"{org_type}_departement_nom", + f"{org_type}_commune_nom", + ], + limit=1, + ) + if df.height == 0: + return {"id": org_id, "nom": None, "departement": None, "commune": None} + r = df.row(0, named=True) + return { + "id": org_id, + "nom": r[f"{org_type}_nom"], + "departement": r[f"{org_type}_departement_nom"], + "commune": r[f"{org_type}_commune_nom"], + } + + +def compute_org_stats(org_type: str, org_id: str) -> dict: + """Statistiques agrégées d'un acheteur ou titulaire.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + other = "titulaire" if org_type == "acheteur" else "acheteur" + where_sql = f'"{org_type}_id" = ?' + params = [org_id] + identite = _org_identite(org_type, org_id) + + totals = aggregate_marches( + select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total', + where_sql=where_sql, + params=params, + ) + nb = int(totals["nb"][0]) + if nb == 0: + return { + "identite": identite, + "nb_marches": 0, + "montant_total": 0, + "repartition_annuelle": [], + f"top_{other}s": [], + "top_cpv": [], + } + + annuelle = aggregate_marches( + select_sql=( + 'CAST(date_part(\'year\', "dateNotification") AS INTEGER) AS annee, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by='date_part(\'year\', "dateNotification")', + order_by="annee", + ) + top_other = aggregate_marches( + select_sql=( + f'"{other}_id" AS id, any_value("{other}_nom") AS nom, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by=f'"{other}_id"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + top_cpv = aggregate_marches( + select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches', + where_sql=where_sql, + params=params, + group_by='"codeCPV"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + return { + "identite": identite, + "nb_marches": nb, + "montant_total": float(totals["montant_total"][0]), + "repartition_annuelle": to_json_records(annuelle), + f"top_{other}s": to_json_records(top_other), + "top_cpv": to_json_records(top_cpv), + } +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (13 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tools stats_acheteur / stats_titulaire (agrégations)" +``` + +--- + +### Task 7: `tools.py` — les 4 fonctions `@mcp_enabled` + +**Files:** + +- Create: `src/mcp/tools.py` +- Test: `tests/mcp/test_tools.py` + +**Interfaces:** + +- Consumes: `queries.search_organisations`, `queries.search_marches`, `queries.compute_org_stats` ; `track_mcp_tool` (Task 2) ; `mcp_enabled` de `dash.mcp`. +- Produces: 4 fonctions décorées `rechercher_organisations`, `stats_acheteur`, `stats_titulaire`, `rechercher_marches`, importables et appelables directement. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_tools.py +from src.mcp import tools + + +def test_all_four_tools_are_callable(): + for name in ( + "rechercher_organisations", + "stats_acheteur", + "stats_titulaire", + "rechercher_marches", + ): + assert callable(getattr(tools, name)) + + +def test_rechercher_organisations_returns_list(): + result = tools.rechercher_organisations("ACHETEUR", "acheteur") + assert isinstance(result, list) + assert any(r["id"] == "123" for r in result) + + +def test_rechercher_marches_returns_meta(): + result = tools.rechercher_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + + +def test_stats_acheteur_returns_stats(): + result = tools.stats_acheteur("123") + assert result["nb_marches"] >= 1 + assert result["identite"]["id"] == "123" + + +def test_stats_titulaire_returns_stats(): + result = tools.stats_titulaire("345") + assert result["nb_marches"] >= 1 +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_tools.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.tools'` + +- [ ] **Step 3: Créer `src/mcp/tools.py`** + +```python +# src/mcp/tools.py +from dash.mcp import mcp_enabled + +from src.mcp import queries +from src.utils.tracking import track_mcp_tool + + +@mcp_enabled(name="rechercher_organisations", expose_docstring=True) +def rechercher_organisations( + query: str, type: str = "acheteur", limite: int = 20 # noqa: A002 +) -> list[dict]: + """Recherche des acheteurs ou titulaires publics par nom. + + Utiliser en premier pour résoudre un nom d'organisation vers son + identifiant, à passer ensuite à stats_acheteur / stats_titulaire. + + query: texte libre (nom d'organisation). + type: "acheteur" ou "titulaire". + Retourne une liste de {id, nom, departement}. + """ + track_mcp_tool("rechercher_organisations", query=query) + return queries.search_organisations(query, type, limite) + + +@mcp_enabled(name="stats_acheteur", expose_docstring=True) +def stats_acheteur(acheteur_id: str) -> dict: + """Statistiques agrégées d'un acheteur public (par identifiant). + + Retourne nombre de marchés, montant total, répartition annuelle, + principaux titulaires et principaux codes CPV. + """ + track_mcp_tool("stats_acheteur") + return queries.compute_org_stats("acheteur", acheteur_id) + + +@mcp_enabled(name="stats_titulaire", expose_docstring=True) +def stats_titulaire(titulaire_id: str) -> dict: + """Statistiques agrégées d'un titulaire (entreprise) par identifiant. + + Retourne nombre de marchés remportés, montant total, répartition + annuelle, principaux acheteurs et principaux codes CPV. + """ + track_mcp_tool("stats_titulaire") + return queries.compute_org_stats("titulaire", titulaire_id) + + +@mcp_enabled(name="rechercher_marches", expose_docstring=True) +def 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, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés publics (DECP). + + Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance + partielle), objet_contient (texte de l'objet), montant_min, montant_max, + date_min / date_max (format YYYY-MM-DD, sur dateNotification), + departement (code département de l'acheteur). + filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les + besoins pointus (mêmes colonnes/opérateurs que l'API REST colibre). + page : numéro de page (50 résultats par page). + Retourne {meta: {page, page_size, total}, marches: [...]}. + """ + track_mcp_tool("rechercher_marches", query=objet_contient) + return queries.search_marches( + acheteur_id=acheteur_id, + titulaire_id=titulaire_id, + cpv=cpv, + objet_contient=objet_contient, + montant_min=montant_min, + montant_max=montant_max, + date_min=date_min, + date_max=date_max, + departement=departement, + page=page, + filtres_avances=filtres_avances, + ) +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_tools.py -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/tools.py tests/mcp/test_tools.py +git commit -m "feat(mcp): expose les 4 fonctions métier via @mcp_enabled" +``` + +--- + +### Task 8: Activation du serveur MCP dans l'app + doc env + +**Files:** + +- Modify: `src/app.py` +- Modify: `.template.env` +- Test: vérification manuelle (import) + suite complète. + +**Interfaces:** + +- Consumes: `src.mcp.tools` (Task 7), `enable_mcp`/`configure_mcp_server` de Dash. +- Produces: serveur MCP monté sur `/_mcp` quand `DASH_MCP_ENABLED=true`, exposant uniquement les 4 tools. + +- [ ] **Step 1: Ajouter le paramètre `enable_mcp` au constructeur `Dash`** + +Dans `src/app.py`, juste avant `app: Dash = Dash(` (vers la ligne 78), ajouter : + +```python +_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" +``` + +Puis, dans l'appel `Dash(...)`, ajouter le paramètre (après `compress=True,`) : + +```python + enable_mcp=_mcp_enabled, +``` + +- [ ] **Step 2: Monter les tools après l'init de l'app** + +Toujours dans `src/app.py`, juste après la ligne `init_api(app.server)`, ajouter : + +```python +# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled, +# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true. +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 +``` + +- [ ] **Step 3: Documenter la variable dans `.template.env`** + +Ajouter à `.template.env` : + +```bash +# Serveur MCP (issue #111). Laisser à false tant que l'authentification +# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP +# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels : +# créer un Custom Dimension slot 1 (scope Action). +DASH_MCP_ENABLED=false +``` + +- [ ] **Step 4: Vérifier l'import des tools (hors app complète)** + +Run: `uv run python -c "import src.mcp.tools; print('ok', [f for f in ('rechercher_organisations','stats_acheteur','stats_titulaire','rechercher_marches')])"` +Expected: affiche `ok [...]` sans erreur d'import. + +- [ ] **Step 5: Lancer la suite de tests MCP complète** + +Run: `uv run pytest tests/mcp/ -v` +Expected: PASS (tous les tests des tâches 1-7). + +- [ ] **Step 6: Lancer la suite complète (dernière tâche du plan)** + +Run: `uv run pytest` +Expected: PASS (aucune régression ; les tests Selenium nécessitent Chrome). + +- [ ] **Step 7: Commit** + +```bash +git add src/app.py .template.env +git commit -m "feat(mcp): activer le serveur MCP via DASH_MCP_ENABLED (scope A #111)" +``` + +--- + +## Vérification manuelle de bout en bout (optionnelle, après Task 8) + +Pour tester le serveur MCP réel avec un vrai client : + +```bash +DASH_MCP_ENABLED=true DATA_FILE_PARQUET_PATH=tests/test.parquet uv run run.py +# dans un autre terminal / client MCP : +claude mcp add colibre-local --transport http --scope user http://127.0.0.1:8050/_mcp +``` + +Puis demander à l'agent d'appeler `rechercher_marches` / `stats_acheteur`. Confirmer que seuls les 4 tools sont visibles (aucun callback d'UI). + +## Self-Review (effectuée) + +- **Couverture spec** : sérialisation (T1), tracking Matomo `action_name="MCP"`+`dimension1` (T2), non-pollution de `track_search` (T3), les 4 tools `rechercher_organisations` (T4) / `rechercher_marches` hybride (T5) / `stats_acheteur`+`stats_titulaire` (T6), surface réduite via `configure_mcp_server` + gating `DASH_MCP_ENABLED` off par défaut (T8), doc env + prérequis Matomo (T8). ✎ Écarts assumés vs spec, dus aux données réelles : pas de libellé CPV (`top_cpv` = `{cpv, nb_marches}`) ; `search_organisations` renvoie `{id, nom, departement}` sans `commune` (colonne absente de la sortie de `search_org`) — l'identité complète (avec commune) reste fournie par `stats_*` via `_org_identite`. Arborescence affinée : logique données regroupée dans `queries.py` (au lieu de `stats.py` seul) pour garder `tools.py` sans SQL. +- **Placeholders** : aucun TODO/TBD ; tout le code est fourni. +- **Cohérence des types** : `search_marches` / `compute_org_stats` / `search_organisations` référencés en T7 avec les signatures définies en T4-T6 ; `to_json_records`, `track_mcp_tool`, `search_org(track=…)` cohérents entre tâches ; contrepartie `top_s` cohérente entre T6 (définition) et T7 (docstrings). diff --git a/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md b/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md new file mode 100644 index 0000000..46a0421 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md @@ -0,0 +1,228 @@ +# 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 …`). diff --git a/src/app.py b/src/app.py index fdcf7a9..d04a342 100644 --- a/src/app.py +++ b/src/app.py @@ -74,6 +74,8 @@ cache.init_app( }, ) +_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" + app: Dash = Dash( server=server, # name="src" (et non "src.app") pour que use_pages enregistre les pages sous @@ -87,6 +89,7 @@ app: Dash = Dash( use_pages=True, suppress_callback_exceptions=True, compress=True, + enable_mcp=_mcp_enabled, meta_tags=META_TAGS, ) @@ -108,6 +111,19 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready init_api(app.server) +# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled, +# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true. +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 + from src.subscriptions.setup import init_subscriptions # noqa: E402 init_subscriptions(app.server) diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp/queries.py b/src/mcp/queries.py new file mode 100644 index 0000000..14833d3 --- /dev/null +++ b/src/mcp/queries.py @@ -0,0 +1,222 @@ +# src/mcp/queries.py +import re + +from src.api.filters import FilterError, build_where +from src.db import aggregate_marches, count_marches, query_marches +from src.db import schema as duckdb_schema +from src.mcp.serialization import to_json_records +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org + +PAGE_SIZE = 50 +TOP_N = 10 +ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} + +# Colonnes renvoyées par rechercher_marches (sortie ciblée, pas SELECT *). +MARCHES_COLUMNS = [ + "uid", + "objet", + "montant", + "dateNotification", + "codeCPV", + "acheteur_id", + "acheteur_nom", + "acheteur_departement_code", + "titulaire_id", + "titulaire_nom", +] + +# (param nommé, colonne decp, opérateur du moteur de filtres API). +# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =. +_NAMED_FILTERS = [ + ("acheteur_id", "acheteur_id", "exact"), + ("titulaire_id", "titulaire_id", "exact"), + ("cpv", "codeCPV", "contains"), + ("objet_contient", "objet", "contains"), + ("montant_min", "montant", "greater"), + ("montant_max", "montant", "less"), + ("date_min", "dateNotification", "greater"), + ("date_max", "dateNotification", "less"), + ("departement", "acheteur_departement_code", "exact"), +] + + +def build_where_args( + named: dict, filtres_avances: dict | None +) -> list[tuple[str, str]]: + """Traduit les paramètres nommés + filtres avancés en tuples (col__op, valeur).""" + args: list[tuple[str, str]] = [] + for param, col, op in _NAMED_FILTERS: + value = named.get(param) + if value is not None: + args.append((f"{col}__{op}", str(value))) + if filtres_avances: + for key, value in filtres_avances.items(): + args.append((key, str(value))) + return args + + +def search_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, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés. Même sémantique de filtres que l'API REST.""" + named = { + "acheteur_id": acheteur_id, + "titulaire_id": titulaire_id, + "cpv": cpv, + "objet_contient": objet_contient, + "montant_min": montant_min, + "montant_max": montant_max, + "date_min": date_min, + "date_max": date_max, + "departement": departement, + } + args = build_where_args(named, filtres_avances) + try: + where_sql, params, order_sql = build_where(args, duckdb_schema) + except FilterError as e: + return {"error": str(e), "champ": e.field} + + page = max(1, int(page)) + offset = (page - 1) * PAGE_SIZE + order_by = order_sql or '"dateNotification" DESC, "uid" DESC' + df = query_marches( + where_sql, + params, + columns=MARCHES_COLUMNS, + order_by=order_by, + limit=PAGE_SIZE, + offset=offset, + ) + total = count_marches(where_sql, params) + return { + "meta": {"page": page, "page_size": PAGE_SIZE, "total": total}, + "marches": to_json_records(df), + } + + +def _extract_plain_text(html_str: str) -> str: + """Extract plain text from HTML link, e.g. '123' -> '123'.""" + match = re.search(r">([^<]+)<", html_str) + return match.group(1) if match else html_str + + +def search_organisations( + query: str, org_type: str = "acheteur", limite: int = 20 +) -> list[dict]: + """Recherche des organisations par nom, résout un nom -> id.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + df = search_org(ORG_FRAMES[org_type], query, org_type, track=False).head(limite) + return [ + { + "id": _extract_plain_text(r[f"{org_type}_id"]), + "nom": _extract_plain_text(r[f"{org_type}_nom"]), + "departement": r.get("Département"), + } + for r in df.to_dicts() + ] + + +def _org_identite(org_type: str, org_id: str) -> dict: + df = query_marches( + where_sql=f'"{org_type}_id" = ?', + params=[org_id], + columns=[ + f"{org_type}_id", + f"{org_type}_nom", + f"{org_type}_departement_nom", + f"{org_type}_commune_nom", + ], + limit=1, + ) + if df.height == 0: + return {"id": org_id, "nom": None, "departement": None, "commune": None} + r = df.row(0, named=True) + return { + "id": org_id, + "nom": r[f"{org_type}_nom"], + "departement": r[f"{org_type}_departement_nom"], + "commune": r[f"{org_type}_commune_nom"], + } + + +def compute_org_stats(org_type: str, org_id: str) -> dict: + """Statistiques agrégées d'un acheteur ou titulaire.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + other = "titulaire" if org_type == "acheteur" else "acheteur" + where_sql = f'"{org_type}_id" = ?' + params = [org_id] + identite = _org_identite(org_type, org_id) + + totals = aggregate_marches( + select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total', + where_sql=where_sql, + params=params, + ) + nb = int(totals["nb"][0]) + if nb == 0: + return { + "identite": identite, + "nb_marches": 0, + "montant_total": 0.0, + "repartition_annuelle": [], + f"top_{other}s": [], + "top_cpv": [], + } + + annuelle = aggregate_marches( + select_sql=( + "CAST(date_part('year', \"dateNotification\") AS INTEGER) AS annee, " + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by="date_part('year', \"dateNotification\")", + order_by="annee", + ) + top_other = aggregate_marches( + select_sql=( + f'"{other}_id" AS id, any_value("{other}_nom") AS nom, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by=f'"{other}_id"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + top_cpv = aggregate_marches( + select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches', + where_sql=where_sql, + params=params, + group_by='"codeCPV"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + return { + "identite": identite, + "nb_marches": nb, + "montant_total": float(totals["montant_total"][0]), + "repartition_annuelle": to_json_records(annuelle), + f"top_{other}s": to_json_records(top_other), + "top_cpv": to_json_records(top_cpv), + } diff --git a/src/mcp/serialization.py b/src/mcp/serialization.py new file mode 100644 index 0000000..9b81ff5 --- /dev/null +++ b/src/mcp/serialization.py @@ -0,0 +1,21 @@ +import datetime +from typing import Any + +import polars as pl + + +def to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]: + """Convertit un DataFrame Polars en liste de dicts JSON-sérialisables. + + Les dates/datetimes deviennent des chaînes ISO 8601 ; les valeurs nulles + restent None. Utilisé par tous les tools MCP pour produire une sortie propre. + """ + return [ + {key: _jsonify(value) for key, value in row.items()} for row in df.to_dicts() + ] + + +def _jsonify(value: Any) -> Any: + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value diff --git a/src/mcp/tools.py b/src/mcp/tools.py new file mode 100644 index 0000000..1f08a3b --- /dev/null +++ b/src/mcp/tools.py @@ -0,0 +1,86 @@ +from dash.mcp import mcp_enabled + +from src.mcp import queries +from src.utils.tracking import track_mcp_tool + + +@mcp_enabled(name="rechercher_organisations", expose_docstring=True) +def rechercher_organisations( + query: str, + type: str = "acheteur", + limite: int = 20, # noqa: A002 +) -> list[dict]: + """Recherche des acheteurs ou titulaires publics par nom. + + Utiliser en premier pour résoudre un nom d'organisation vers son + identifiant, à passer ensuite à stats_acheteur / stats_titulaire. + + query: texte libre (nom d'organisation). + type: "acheteur" ou "titulaire". + Retourne une liste de {id, nom, departement}. + """ + track_mcp_tool("rechercher_organisations", query=query) + return queries.search_organisations(query, type, limite) + + +@mcp_enabled(name="stats_acheteur", expose_docstring=True) +def stats_acheteur(acheteur_id: str) -> dict: + """Statistiques agrégées d'un acheteur public (par identifiant). + + Retourne nombre de marchés, montant total, répartition annuelle, + principaux titulaires et principaux codes CPV. + """ + track_mcp_tool("stats_acheteur") + return queries.compute_org_stats("acheteur", acheteur_id) + + +@mcp_enabled(name="stats_titulaire", expose_docstring=True) +def stats_titulaire(titulaire_id: str) -> dict: + """Statistiques agrégées d'un titulaire (entreprise) par identifiant. + + Retourne nombre de marchés remportés, montant total, répartition + annuelle, principaux acheteurs et principaux codes CPV. + """ + track_mcp_tool("stats_titulaire") + return queries.compute_org_stats("titulaire", titulaire_id) + + +@mcp_enabled(name="rechercher_marches", expose_docstring=True) +def 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, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés publics (DECP). + + Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance + partielle), objet_contient (texte de l'objet), montant_min, montant_max, + date_min / date_max (format YYYY-MM-DD, sur dateNotification), + departement (code département de l'acheteur). + filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les + besoins pointus (mêmes colonnes/opérateurs que l'API REST colibre). + page : numéro de page (50 résultats par page). + Retourne {meta: {page, page_size, total}, marches: [...]}. + """ + track_mcp_tool("rechercher_marches", query=objet_contient) + return queries.search_marches( + acheteur_id=acheteur_id, + titulaire_id=titulaire_id, + cpv=cpv, + objet_contient=objet_contient, + montant_min=montant_min, + montant_max=montant_max, + date_min=date_min, + date_max=date_max, + departement=departement, + page=page, + filtres_avances=filtres_avances, + ) diff --git a/src/utils/search.py b/src/utils/search.py index df998ad..57252ae 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -5,7 +5,9 @@ from src.utils.table import add_links from src.utils.tracking import track_search -def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: +def search_org( + dff: pl.DataFrame, query: str, org_type: str, track: bool = True +) -> pl.DataFrame: """ Search in either 'acheteur' or 'titulaire' DataFrame. @@ -18,7 +20,8 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: return dff.select(pl.lit(False).alias("matches")) # Enregistrement des recherche dans Matomo - track_search(query, "home_page_search") + if track: + track_search(query, "home_page_search") # Normalize query normalized_query = unidecode(query.strip()).upper() diff --git a/src/utils/tracking.py b/src/utils/tracking.py index 86e5377..e7ba173 100644 --- a/src/utils/tracking.py +++ b/src/utils/tracking.py @@ -28,3 +28,35 @@ def track_search(query, category): url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params, ).raise_for_status() + + +def track_mcp_tool(tool_name: str, query: str | None = None) -> None: + """Enregistre un appel d'outil MCP dans Matomo (best-effort, prod uniquement). + + `action_name="MCP / "`, `dimension1=`. Si l'outil porte une + requête texte, elle est envoyée en `search`. Nécessite un Custom Dimension + slot 1 (scope Action) configuré côté Matomo — sinon `dimension1` est ignoré. + Ne lève jamais : une panne Matomo ne doit pas casser l'appel du tool. + """ + if DEVELOPMENT or not os.getenv("MATOMO_DOMAIN"): + return + params = { + "idsite": os.getenv("MATOMO_ID_SITE"), + "url": "https://colibre.fr/_mcp", + "rec": "1", + "action_name": f"MCP / {tool_name}", + "dimension1": tool_name, + "rand": uuid.uuid4().hex, + "apiv": "1", + "h": localtime().tm_hour, + "m": localtime().tm_min, + "s": localtime().tm_sec, + "token_auth": os.getenv("MATOMO_TOKEN"), + } + if query: + params["search"] = query + params["search_cat"] = "mcp" + try: + post(url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params) + except Exception: + pass diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py new file mode 100644 index 0000000..6dafde9 --- /dev/null +++ b/tests/mcp/test_queries.py @@ -0,0 +1,156 @@ +import pytest + +import src.utils.search as search_mod +from src.mcp.queries import ( + build_where_args, + compute_org_stats, + search_marches, + search_organisations, +) +from src.utils.data import DF_ACHETEURS +from src.utils.search import search_org + + +def test_search_org_track_false_skips_track_search(monkeypatch): + calls = [] + monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c))) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=False) + + assert calls == [] + + +def test_search_org_track_true_calls_track_search(monkeypatch): + calls = [] + monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c))) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True) + + assert calls == [("ACHETEUR", "home_page_search")] + + +def test_search_organisations_finds_known_acheteur(): + result = search_organisations("ACHETEUR", "acheteur") + assert any(r["id"] == "123" for r in result) + first = next(r for r in result if r["id"] == "123") + assert set(first.keys()) == {"id", "nom", "departement"} + # Vérifier que le nom a été extrait en texte plain (HTML strippé) + assert first["nom"] == "ACHETEUR 1" + assert "<" not in first["nom"] # Défense : aucun markup HTML ne s'échappe + + +def test_search_organisations_finds_known_titulaire(): + result = search_organisations("TITULAIRE", "titulaire") + assert any(r["id"] == "345" for r in result) + first = next(r for r in result if r["id"] == "345") + assert set(first.keys()) == {"id", "nom", "departement"} + # Vérifier que le nom a été extrait en texte plain (HTML strippé), + # même chemin SIRET/non-SIRET différent de celui du test acheteur. + assert first["id"] == "345" + assert first["nom"] == "TITULAIRE 1" + assert "<" not in first["id"] + assert "<" not in first["nom"] + + +def test_search_organisations_invalid_type_raises(): + with pytest.raises(ValueError): + search_organisations("x", "autre") + + +def test_search_organisations_respects_limite(): + result = search_organisations("ACHETEUR", "acheteur", limite=1) + assert len(result) <= 1 + + +def test_build_where_args_named_params(): + args = build_where_args( + {"acheteur_id": "123", "montant_min": 5, "objet_contient": "test"}, None + ) + assert ("acheteur_id__exact", "123") in args + assert ("montant__greater", "5") in args + assert ("objet__contains", "test") in args + + +def test_build_where_args_merges_filtres_avances(): + args = build_where_args( + {"acheteur_id": "123"}, {"titulaire_departement_code__exact": "35"} + ) + assert ("acheteur_id__exact", "123") in args + assert ("titulaire_departement_code__exact", "35") in args + + +def test_search_marches_returns_meta_and_rows(): + result = search_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + assert result["meta"]["page"] == 1 + assert result["meta"]["page_size"] == 50 + assert any(m["acheteur_id"] == "123" for m in result["marches"]) + # dates sérialisées en ISO + assert result["marches"][0]["dateNotification"] == "2025-01-01" + + +def test_search_marches_no_match_is_empty(): + result = search_marches(acheteur_id="inconnu-xyz") + assert result["meta"]["total"] == 0 + assert result["marches"] == [] + + +def test_search_marches_bad_filter_returns_error(): + result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) + assert "error" in result + + +def test_search_marches_montant_min_filters_correctly(): + assert search_marches(montant_min=100)["meta"]["total"] == 0 + assert search_marches(montant_min=1)["meta"]["total"] >= 1 + + +def test_search_marches_date_min_max_filters_correctly(): + assert search_marches(date_min="2025-01-01")["meta"]["total"] >= 1 + assert search_marches(date_min="2025-01-02")["meta"]["total"] == 0 + assert search_marches(date_max="2025-01-01")["meta"]["total"] >= 1 + assert search_marches(date_max="2024-12-31")["meta"]["total"] == 0 + + +def test_search_marches_cpv_filters_correctly(): + assert search_marches(cpv="716")["meta"]["total"] >= 1 + assert search_marches(cpv="999")["meta"]["total"] == 0 + + +def test_search_marches_page_2_is_empty_with_correct_meta(): + result = search_marches(acheteur_id="123", page=2) + assert result["meta"]["page"] == 2 + assert result["marches"] == [] + + +def test_search_marches_page_zero_is_clamped_to_one(): + result = search_marches(acheteur_id="123", page=0) + assert result["meta"]["page"] == 1 + + +def test_compute_org_stats_acheteur_known(): + stats = compute_org_stats("acheteur", "123") + assert stats["nb_marches"] >= 1 + assert stats["montant_total"] == 10 + assert stats["identite"]["id"] == "123" + assert stats["identite"]["nom"] == "ACHETEUR 1" + assert "top_titulaires" in stats + assert "top_cpv" in stats + # répartition annuelle dérivée de dateNotification (2025) + annees = [row["annee"] for row in stats["repartition_annuelle"]] + assert 2025 in annees + + +def test_compute_org_stats_titulaire_known(): + stats = compute_org_stats("titulaire", "345") + assert stats["nb_marches"] >= 1 + assert "top_acheteurs" in stats + + +def test_compute_org_stats_unknown_is_empty(): + stats = compute_org_stats("acheteur", "inconnu-xyz") + assert stats["nb_marches"] == 0 + assert stats["montant_total"] == 0 + assert stats["repartition_annuelle"] == [] + assert stats["top_titulaires"] == [] + assert stats["top_cpv"] == [] diff --git a/tests/mcp/test_serialization.py b/tests/mcp/test_serialization.py new file mode 100644 index 0000000..ad35b88 --- /dev/null +++ b/tests/mcp/test_serialization.py @@ -0,0 +1,20 @@ +import datetime + +import polars as pl + +from src.mcp.serialization import to_json_records + + +def test_dates_become_iso_strings(): + df = pl.DataFrame({"d": [datetime.date(2025, 1, 1)], "n": [10]}) + assert to_json_records(df) == [{"d": "2025-01-01", "n": 10}] + + +def test_none_is_preserved(): + df = pl.DataFrame({"nom": [None], "x": [3]}) + assert to_json_records(df) == [{"nom": None, "x": 3}] + + +def test_strings_and_numbers_untouched(): + df = pl.DataFrame({"s": ["abc"], "f": [1.5]}) + assert to_json_records(df) == [{"s": "abc", "f": 1.5}] diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py new file mode 100644 index 0000000..18ac863 --- /dev/null +++ b/tests/mcp/test_tools.py @@ -0,0 +1,33 @@ +from src.mcp import tools + + +def test_all_four_tools_are_callable(): + for name in ( + "rechercher_organisations", + "stats_acheteur", + "stats_titulaire", + "rechercher_marches", + ): + assert callable(getattr(tools, name)) + + +def test_rechercher_organisations_returns_list(): + result = tools.rechercher_organisations("ACHETEUR", "acheteur") + assert isinstance(result, list) + assert any(r["id"] == "123" for r in result) + + +def test_rechercher_marches_returns_meta(): + result = tools.rechercher_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + + +def test_stats_acheteur_returns_stats(): + result = tools.stats_acheteur("123") + assert result["nb_marches"] >= 1 + assert result["identite"]["id"] == "123" + + +def test_stats_titulaire_returns_stats(): + result = tools.stats_titulaire("345") + assert result["nb_marches"] >= 1 diff --git a/tests/mcp/test_tracking.py b/tests/mcp/test_tracking.py new file mode 100644 index 0000000..e4067b9 --- /dev/null +++ b/tests/mcp/test_tracking.py @@ -0,0 +1,42 @@ +import src.utils.tracking as tracking + + +def test_track_mcp_tool_sends_action_and_dimension(monkeypatch): + captured = {} + + def fake_post(url, params): + captured["url"] = url + captured["params"] = params + + class _R: + def raise_for_status(self): + pass + + return _R() + + monkeypatch.setattr(tracking, "DEVELOPMENT", False) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + monkeypatch.setenv("MATOMO_ID_SITE", "1") + + tracking.track_mcp_tool("rechercher_marches", query="informatique") + + assert captured["params"]["action_name"] == "MCP / rechercher_marches" + assert captured["params"]["dimension1"] == "rechercher_marches" + assert captured["params"]["search"] == "informatique" + + +def test_track_mcp_tool_noop_in_development(monkeypatch): + called = False + + def fake_post(url, params): + nonlocal called + called = True + + monkeypatch.setattr(tracking, "DEVELOPMENT", True) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + + tracking.track_mcp_tool("stats_acheteur") + + assert called is False