Club Alpin MCP
**Français** · [English](README.en.md)
# Club Alpin MCP
Un petit serveur [Model Context Protocol](https://modelcontextprotocol.io) **en lecture
seule** qui permet à un assistant IA (Claude Desktop, Claude Code, Hermes…) de lire le site
web d'une section du **Club Alpin Français** qui utilise la plateforme open source
[plateforme-club-alpin](https://github.com/Club-Alpin-Lyon-Villeurbanne/plateforme-club-alpin)
(utilisée par plusieurs clubs, par exemple Lyon-Villeurbanne, Chambéry, Clermont-Ferrand).
- **Données publiques** : l'agenda des sorties collectives, le détail d'une sortie, les
formations.
- **Vos données de membre** (facultatif, avec vos identifiants) : vos inscriptions à venir,
vos sorties passées, votre profil (adhésion, responsabilités, compétences FFCAM validées).
Il ne peut **pas** vous inscrire, vous désinscrire ni modifier quoi que ce soit sur le site :
le code n'envoie que des requêtes `GET` vers une liste explicite de pages autorisées, plus
le `POST` du formulaire de connexion (voir [Garanties de lecture seule](#garanties-de-lecture-seule)).
> Projet non officiel, sans lien avec la FFCAM ni avec les mainteneurs de la plateforme.
## Outils
| Outil | Paramètres | Renvoie |
| --- | --- | --- |
| `list_activities` | – | Les activités du club (*commissions*) et leurs codes : `randonnee`, `escalade`, `alpinisme`, `formation`… |
| `list_sorties` | `start_date`, `end_date` (AAAA-MM-JJ, par défaut aujourd'hui → +30 jours, 92 jours maximum), `activity` (code ou nom ; `"rando"` fonctionne s'il n'y a pas d'ambiguïté), `difficulty` (texte contenu dans la cotation, par ex. `"T3"`, `"PD"`, `"5c"`), `availability` (`any`, `open`, `not_yet_open`, `full`, `closed`), `search` (titre), `include_cancelled`, `limit` | Les sorties qui commencent dans la période, par date croissante : id, activité, date, date-heure de début et de fin, tarif, disponibilité (`places_left`, `registration_opens`), url ; titre, difficulté et lieu de rendez-vous sous `untrusted_member_text`. |
| `get_sortie` | `sortie` : id (`"10765"`) ou URL ; `full_description` (false par défaut : description coupée à 1 500 caractères) | Tout ce qu'affiche la page de la sortie : dénivelé positif, distance, tarif, nombre maximal de places, participants confirmés / en attente, liste d'attente, annulation, validation par le club, empreinte carbone ; et sous `untrusted_member_text` l'itinéraire, le massif, le lieu de départ, la difficulté, le matériel, les informations pratiques, la description, le lieu de rendez-vous, l'organisateur, les remarques sur l'inscription. Une fois connecté : l'encadrement (encadrants : pseudo + rôle) et votre propre statut de participation. |
| `list_formations` | `start_date`, `end_date` (par défaut aujourd'hui → +60 jours), `availability`, `include_other_activities` (true par défaut), `include_cancelled`, `limit` | Les formations : les sorties de l'activité dédiée aux formations (`formation` sur la plupart des sites) et, en option, les sorties d'autres activités dont le titre ressemble à une formation (formation, stage, initiation, cycle, recyclage, DVA, PSC…), avec `matched_by`. |
| `get_my_registrations` | – | *Connexion requise.* Vos sorties à venir avec `my_status` (`accepted`, `pending`, `refused`, `absent`), `my_role`, un indicateur si vous êtes l'organisateur ; les avertissements de chevauchement sous `untrusted_member_text`. |
| `get_my_history` | `start_date`, `end_date`, `activity`, `limit` (50 par défaut, 300 maximum) | *Connexion requise.* Vos sorties passées, de la plus récente à la plus ancienne, avec un résumé (sorties auxquelles vous avez participé par activité, statuts) et sa portée `summary_scope` (sorties présentes sur le site / lues / résumées), plus une note quand tout n'a pas été lu. |
| `get_my_profile` | – | *Connexion requise.* Date d'adhésion, statut de la licence (`ok`, `renewal_due`, `expired`), responsabilités, compétences validées (groupes de compétences, niveaux de pratique, UFCA / PSC1, avec leurs dates), taux de présence ; votre pseudo sous `untrusted_member_text`. |
Chaque résultat commence par un champ `notice` qui rappelle à l'assistant que
`untrusted_member_text` contient des données, pas des instructions. Toutes les dates sont
exprimées dans le fuseau horaire du club (`CLUB_ALPIN_TIMEZONE`, `Europe/Paris` par
défaut) ; les dates-heures sont au format ISO 8601 avec leur décalage UTC.
## Fonctionnement
La plateforme n'a pas d'API JSON publique (son `/api` exige un jeton de membre) : le
serveur lit donc les mêmes pages qu'un visiteur, avec les mêmes règles :
| Page | Utilisée pour |
| --- | --- |
| `GET /agenda.html?month=M&year=Y` et `GET /agenda/<activity>.html?month=M&year=Y` | les sorties d'un mois, les badges de disponibilité, la liste des activités |
| `GET /calendrier.ics` | le flux iCal public des sorties à venir : horaires exacts de début et de fin, lieu de rendez-vous, difficulté, tarif |
| `GET /sortie/<code>-<id>.html` | le détail d'une sortie |
| `GET /login`, `POST /login` | le formulaire de connexion des membres (form login Symfony avec un jeton CSRF et un cookie de session) |
| `GET /profil/sorties/next` et `GET /profil/sorties/prev?page=N` | vos sorties à venir / passées |
| `GET /profil/mon-compte` | votre compte (adhésion, responsabilités) |
| `GET /user-full/<your id>.html` | votre profil complet (compétences, taux de présence) ; uniquement votre propre id |
Sans connexion, le site masque le lieu de rendez-vous, les horaires et le tarif d'une
sortie ; le serveur les complète alors à partir du flux iCal public lorsque la sortie est à
venir.
Si une page n'a pas l'allure attendue (un autre mois que celui demandé, un agenda qui
annonce des sorties dont aucune ne peut être lue, un tableau manquant…), l'outil échoue
avec une erreur explicite au lieu de renvoyer une liste vide ou fausse.
## Garanties de lecture seule
Imposées par le code, et vérifiées par les tests (`tests/test_readonly.py`) :
1. **Liste d'autorisation.** `check_allowed()` refuse toute requête dont la méthode,
l'hôte, le chemin ou les paramètres d'URL ne figurent pas dans la liste ci-dessus. Seul
`GET` est autorisé, plus `POST /login` sans aucune query string. Les routes de la
plateforme comme `POST /sortie/<id>/rejoindre` (inscription),
`POST /sortie/remove-participant/<id>` (désinscription), le formulaire de profil, les
liens GET qui modifient des données (`/profil/supprimer-ma-photo`,
`/retirer-responsabilite/…`) et le paramètre d'emprunt d'identité `?_switch_user=` de
Symfony sont tous refusés, et les tests essaient chacun d'eux.
2. **Double vérification.** La vérification a lieu avant l'envoi, puis de nouveau dans un
hook de requête httpx sur chaque requête réellement envoyée : même du code qui
appellerait directement le client HTTP ne pourrait pas envoyer d'écriture. Le hook
vérifie aussi que le `POST` de connexion contient exactement les trois champs de
connexion, et que `/user-full/<id>.html` n'est lu que pour votre propre id.
3. **Aucune redirection suivie**, aucun lien suivi, aucun formulaire soumis à part le
formulaire de connexion, et les réglages de proxy / `.netrc` issus de l'environnement
sont ignorés (`trust_env=False`).
4. **Uniquement des outils de lecture.** Tous les outils sont déclarés avec l'annotation
MCP `readOnlyHint: true` ; un test statique vérifie que le code source ne contient qu'un
seul endroit qui envoie des requêtes et un seul endroit qui envoie le `POST` de
connexion.
### Votre mot de passe et votre compte
- Le mot de passe n'est placé que dans le corps du `POST` de connexion. Il n'est jamais
journalisé, et il est retiré de toutes les chaînes de tous les résultats et de toutes les
erreurs (même si le site l'a renvoyé). Les logs des bibliothèques HTTP sont réduits au
silence afin que rien n'atteigne le canal stdio MCP.
- La plateforme verrouille un compte après 5 échecs de connexion en 15 minutes ; le
serveur n'insiste donc jamais : après un mot de passe refusé, il **désactive la connexion
jusqu'à son redémarrage** (le mot de passe ne peut pas changer d'ici là) ; après « trop
de tentatives », un dépassement du délai de la requête de connexion ou une réponse
inattendue, il attend 15 minutes avant de réessayer.
- Les cookies de session ne sont conservés qu'en mémoire.
- `get_my_profile` laisse volontairement de côté votre e-mail, vos téléphones, votre
adresse, votre date de naissance, votre numéro de licence et vos liens familiaux. Le
détail d'une sortie ne liste jamais les autres participants (seulement les pseudos des
encadrants et les effectifs), même quand vos droits permettraient de voir leurs
coordonnées, et une annulation est signalée sans le pseudo du membre qui a annulé.
### Textes rédigés par les membres du club
Titres, descriptions, listes de matériel, itinéraires, lieux de rendez-vous, informations
pratiques, remarques… sont rédigés par des membres du club, et toute personne ayant un
compte peut créer une sortie. Un assistant connecté à ce serveur peut aussi disposer d'un
terminal, d'un accès web ou de tâches planifiées ; ces textes sont donc traités comme des
entrées non fiables :
- ils sont regroupés sous `untrusted_member_text` dans chaque résultat, jamais mélangés
aux champs structurés ;
- chaque résultat commence par un champ `notice`, et le serveur envoie le même
avertissement en tant qu'`instructions` MCP (certains clients, comme Hermes, ne
transmettent pas les `instructions` au modèle : le `notice` est là pour eux) ;
- les éléments masqués pour un lecteur humain (`display:none`, `visibility:hidden`,
`font-size:0`, `opacity:0`, `hidden`) sont supprimés, car le sanitizer HTML de la
plateforme conserve les attributs `style` ;
- les caractères Unicode invisibles sont retirés : les caractères de formatage (largeur
nulle, contrôles bidi, caractères « tag » U+E0000–U+E007F) et les caractères de
contrôle ;
- les descriptions sont coupées à 1 500 caractères, sauf si `full_description` est
demandé.
Cela réduit le risque, sans pouvoir rendre un texte inoffensif. Ne donnez pas à un agent
qui utilise ce serveur plus de pouvoirs qu'il n'en a besoin (voir
[Déploiement avec un agent IA](#déploiement-avec-un-agent-ia)).
## Prérequis et installation
- [uv](https://docs.astral.sh/uv/) et Python 3.10 ou ultérieur.
- L'adresse d'un site de club qui utilise plateforme-club-alpin. Pour vérifier le vôtre,
ouvrez `https://<your site>/agenda.html` et `https://<your site>/calendrier.ics`.
`club_alpin_mcp.py` déclare ses dépendances dans son en-tête `# /// script`, avec des
bornes supérieures (`mcp` < 2, `httpx` < 1, `beautifulsoup4` < 5, `anyio` < 5), et
`club_alpin_mcp.py.lock` fixe leurs versions exactes et leurs empreintes (hashes). Lancez
le serveur avec `run.sh`, qui exécute :
```bash
uv run --quiet --locked --no-project --script club_alpin_mcp.py
```
avec `set -eu` et `UV_PYTHON_DOWNLOADS=never` : il refuse de démarrer plutôt que de
résoudre de nouvelles versions, ne télécharge jamais Python, et ne télécharge plus rien une
fois que le cache uv contient les paquets. `uv` est pris dans `$UV` s'il est défini, sinon
dans le `PATH` ; définissez `CLUB_ALPIN_PYTHON` pour choisir l'interpréteur. Après avoir
modifié l'en-tête, mettez à jour le lockfile avec `uv lock --script club_alpin_mcp.py` et
relisez le diff.
## Configuration
| Variable | Description |
| --- | --- |
| `CLUB_ALPIN_URL` | **Obligatoire.** Racine du site du club, par ex. `https://www.clubalpinlyon.fr` (https uniquement ; `http://localhost` est accepté pour une instance locale) |
| `CLUB_ALPIN_EMAIL` | Facultatif. L'e-mail de connexion de votre compte membre ; active les outils `get_my_*` |
| `CLUB_ALPIN_PASSWORD` | Facultatif. Le mot de passe de votre compte membre |
| `CLUB_ALPIN_TIMEZONE` | Facultatif, `Europe/Paris` par défaut |
| `CLUB_ALPIN_FORMATION_ACTIVITIES` | Facultatif. Codes des activités qui regroupent les formations, séparés par des virgules ; par défaut : les activités dont le nom contient « formation » |
| `CLUB_ALPIN_REQUEST_INTERVAL` | Facultatif. Délai minimal entre deux requêtes, en secondes ; `1.0` par défaut, au moins `0.5` |
Copiez `.env.example` vers `.env` et remplissez-le (le fichier `.env` est ignoré par git),
ou passez les variables dans la configuration de votre client MCP. Sans
`CLUB_ALPIN_EMAIL` / `CLUB_ALPIN_PASSWORD`, seuls les outils publics fonctionnent.
## Autotest rapide
Appelle chaque outil une fois et affiche un résumé en JSON, sans transport MCP (environ 8
requêtes, 13 avec un compte), en terminant par la liste des requêtes envoyées (uniquement
des `GET`, plus un `POST /login` avec un compte) :
```bash
cp .env.example .env # puis modifiez .env (ignoré par git)
set -a; . ./.env; set +a; ./run.sh selftest
# ou, sans run.sh : uv run --env-file .env --locked --no-project --script club_alpin_mcp.py selftest
```
La partie publique montre quelques sorties à venir du club. La partie `member` n'affiche
aucune valeur personnelle : seulement les nombres, statuts, rôles et dates de vos
inscriptions, la portée du résumé de votre historique, le statut de votre licence et les
noms des champs de profil trouvés.
## Utilisation avec un client MCP
```json
{
"mcpServers": {
"club-alpin": {
"command": "/absolute/path/to/run.sh",
"env": {
"CLUB_ALPIN_URL": "https://www.clubalpinlyon.fr",
"CLUB_ALPIN_EMAIL": "you@example.org",
"CLUB_ALPIN_PASSWORD": "…"
}
}
}
}
```
Le serveur parle MCP sur stdio. Chaque appel d'outil s'exécute dans un thread séparé et
est interrompu au bout de 60 secondes. Seul le trafic HTTPS sortant vers le site du club
est nécessaire.
## Déploiement avec un agent IA
Lorsque le client est un agent autonome (Hermes ou équivalent) qui dispose aussi d'outils
de terminal, de fichiers, de web ou de planification :
- Ne mettez jamais le mot de passe dans un fichier versionné (y compris le dépôt de
configuration de l'agent) ni dans un fichier que les outils de fichiers de l'agent
peuvent lire.
- Assurez-vous que le terminal de l'agent ne peut pas lire l'environnement du serveur :
lancez le serveur MCP depuis un lanceur, un conteneur ou un compte utilisateur dont
l'environnement et les fichiers de secrets sont inaccessibles aux outils de l'agent (pas
de `.env` partagé, pas de `/proc/<pid>/environ` lisible).
- Préférez un compte dédié, sans responsabilités sur le site, si vous ne voulez que les
outils publics et vos propres inscriptions, et ne fournissez aucun identifiant quand les
outils publics suffisent.
- Limitez les autres pouvoirs de l'agent à ce dont il a besoin : les textes des sorties
sont rédigés par d'autres membres (voir
[Textes rédigés par les membres du club](#textes-rédigés-par-les-membres-du-club)).
## Ménager le site du club
Le serveur envoie au plus une requête par seconde (`CLUB_ALPIN_REQUEST_INTERVAL`), garde
les pages publiques en mémoire pendant un certain temps (pages de sortie 2 minutes, pages
d'agenda 10 minutes, flux iCal 15 minutes, liste des activités 1 heure), utilise des délais
d'expiration de 20 secondes, refuse en cours de téléchargement les réponses de plus de
5 Mo, envoie un `User-Agent` explicite
(`mcp-club-alpin/<version> (read-only MCP server; python-httpx)`), et lit au plus 4 pages
d'agenda par appel à `list_sorties`. Il n'explore pas le site (pas de crawl).
## Tests
```bash
uv run pytest # hors ligne : parseurs, outils, lecture seule, contrôles de la connexion et des secrets
CLUB_ALPIN_LIVE_TESTS=1 CLUB_ALPIN_URL=https://www.clubalpinlyon.fr uv run pytest -m live
```
Les tests hors ligne s'exécutent sur `tests/fixtures/` : des copies anonymisées des vraies
pages publiques, et des pages membres construites à partir des templates Twig de la
plateforme (faux noms, adresses `example.org`, faux numéros). Le test sur le vrai site
(`live`) ne lit que des pages publiques.
### Test manuel des outils membres
Les pages membres ne peuvent pas être testées automatiquement sans compte. Avec votre
propre compte, sur votre propre machine :
1. Copiez `.env.example` vers `.env` et renseignez vous-même `CLUB_ALPIN_URL`,
`CLUB_ALPIN_EMAIL`, `CLUB_ALPIN_PASSWORD`, dans votre éditeur (ne collez jamais le mot
de passe dans une conversation).
2. Lancez l'autotest ci-dessus. Vérifiez la section `member` :
- `my_registrations` : le nombre et les statuts correspondent à la page du site
*Mes sorties à venir* ;
- `my_history.summary_scope.outings_on_site` correspond à *Mes sorties passées* ;
- `registered_outing_detail` : `logged_in` vaut `true` et `my_participation_status`
correspond à la page de la sortie ;
- `my_profile` : le statut de la licence et les champs trouvés correspondent à
*Mon compte* ;
- `requests_sent` : uniquement des `GET` et un seul `POST /login`.
Une erreur `Login refused by …` cite le message du site ; après elle, le serveur ne
réessaie pas avant d'être redémarré. Une erreur `Unexpected … layout` signifie qu'une
page membre diffère de ce qu'attend le parseur : merci d'ouvrir une issue (sans données
personnelles).
3. Si vous le souhaitez, dans un client MCP (ou
`npx @modelcontextprotocol/inspector ./run.sh`), appelez `get_my_registrations`,
`get_my_history`, `get_my_profile` et `get_sortie` sur une sortie à laquelle vous êtes
inscrit, et comparez avec le site.
4. Vérifiez que rien n'a changé sur votre compte (inscriptions, profil, photo), et que votre
mot de passe n'apparaît nulle part dans les résultats ni dans les logs.
## Limites
- Analyse du HTML : une modification des templates de la plateforme peut casser un outil
jusqu'à la mise à jour du parseur (l'outil échoue alors avec une erreur de structure de
page plutôt que de renvoyer des données fausses).
- `list_sorties` renvoie les sorties qui *commencent* dans la période ; les longs cycles
commencés plus tôt ne sont pas répétés. L'agenda ne couvre que les années de 2001 à
l'année prochaine + 1.
- La difficulté, les horaires exacts et le lieu de rendez-vous proviennent du flux iCal,
qui ne couvre que les sorties à venir et non annulées, et **au plus 200 d'entre elles**
(une limite de la plateforme) : pour les sorties plus lointaines, ces champs peuvent
manquer dans les listes (`get_sortie` affiche toujours ce que montre la page de la
sortie).
- `places_left` est le chiffre du site : nombre maximal de places moins les participants
confirmés (les demandes en attente ne sont pas déduites, et le quota en ligne peut être
inférieur : voir `get_sortie`).
- Les formations hors de l'activité dédiée aux formations sont repérées par des mots-clés
dans le titre (en français).
- Les compétences (`get_my_profile`) ne sont affichées que si le club les active sur son
site.
- `get_my_history` lit au plus 10 pages (300 sorties) par appel ; son résumé ne couvre que
les sorties lues (voir `summary_scope`).
- Sans connexion, certains champs d'une sortie sont masqués par le site
(`hidden_without_login`).
## Licence
MIT
TDQS
Scored across 7 tools
Tools split cleanly by resource: reference data (list_activities), outing browsing (list_sorties/get_sortie), personal data (get_my_registrations/get_my_history/get_my_profile). The only fuzziness is list_formations vs list_sorties with activity=formation, but the description explains the extra title-matching and matched_by field, so an agent can choose correctly. Upcoming vs past personal outings are clearly separated by name and description.
Uniform snake_case verb_noun convention throughout, with a predictable list_/get_ prefix and a coherent get_my_ prefix for the credential-scoped tools. No mixed styles or vague verbs.
Seven tools is well-scoped for a read-only club site client: one reference lister, a browse pair, a formation lister, and three personal-data tools. No redundant or filler tools, and no need to split further.
The read-only surface is coherent: discover activities, browse outings with rich filters, drill into detail, and retrieve personal registrations, history and profile. Writing is intentionally out of scope (stated repeatedly), which is a deliberate limitation rather than an oversight, though a directory/massif search or a way to fetch activity metadata in richer form would round it out.