ecole-directe-mcp
by Mathr81
README.md
# ecoledirecte-mcp
Serveur MCP personnel pour École Directe : notes, devoirs, emploi du temps,
vie scolaire, vie de classe, fil d'actualité, téléchargement de documents.
## Installation locale (stdio)
npm install
npm run build
node dist/cli/index.js login
`login` demande l'identifiant et le mot de passe École Directe, puis, à la
première connexion depuis cette machine, une question de sécurité (QCM) —
c'est obligatoire côté École Directe, il n'y a pas de contournement.
La session est enregistrée dans `~/.config/ecoledirecte-mcp/session.json`
(permissions 600) et rafraîchie automatiquement ensuite.
## Utilisation avec Claude Code
claude mcp add ecoledirecte -- node /chemin/absolu/vers/dist/cli/index.js serve
Le serveur démarre même sans session valide : `get_auth_status` sert
justement à diagnostiquer ce cas, et les autres outils renvoient une erreur
explicite au lieu de faire tomber le serveur.
## Outils exposés
| Outil | Rôle |
| --- | --- |
| `get_auth_status` | État de la session (présence, date du dernier rafraîchissement) |
| `get_grades` | Notes d'une année scolaire, avec leur période |
| `get_averages` | Moyennes par période, par matière et générale, calculées à partir des notes |
| `get_homework` | Cahier de textes entre deux dates : devoirs (interrogations, pièces jointes) et contenu des séances |
| `mark_homework_done` | Marquer un devoir fait / non fait (écriture) |
| `get_timetable` | Emploi du temps entre deux dates, trié, avec cours annulés et modifiés |
| `get_school_life` | Vie scolaire (absences, retards, sanctions) |
| `get_class_life` | Vie de la classe et commentaires |
| `get_timeline` | Fil d'actualité personnel |
| `get_messages` | Liste les messages d'un dossier (en-têtes seulement) |
| `read_message` | Contenu d'un message, HTML retiré, avec ses pièces jointes |
| `get_documents` | Bulletins, certificats et autres documents, années archivées comprises |
| `download_document` | Télécharge un document dans `DOWNLOAD_DIR`, sous son vrai nom, et renvoie son texte (PDF, DOCX, TXT, HTML) ; plus son chemin en local, ou un lien temporaire en HTTP |
La messagerie est en deux outils parce que l'API l'impose : la liste
renvoie `content: ""` pour chaque message, les corps n'existent que sur
l'endpoint par message. Les pièces jointes se récupèrent avec
`download_document` en passant `fileType` = `PIECE_JOINTE`.
En HTTP, le fichier atterrit sur le serveur, hors de portée du client : le
chemin ne lui servirait à rien. `download_document` renvoie donc le **texte**
du document, ce dont le modèle a besoin (tronqué à 100 000 caractères), et un
**lien temporaire** `…/downloads/<jeton>` valable une heure pour que tu
récupères le fichier toi-même. Un navigateur ne sait pas envoyer le jeton MCP :
c'est le jeton aléatoire de 256 bits dans l'URL qui fait office d'accès, et il
ne désigne qu'un fichier que le serveur a lui-même écrit. Les liens vivent en
mémoire et disparaissent au redémarrage. L'URL de base est l'origine de
`MCP_PUBLIC_URL` si elle est définie, sinon la première entrée de
`MCP_ALLOWED_HOSTS`.
Les moyennes sont **calculées** et non lues : un établissement peut ne les
publier qu'une fois la période close (`moyenneUniquementPeriodeCloture`), et
École Directe renvoie alors des champs vides. Chaque note compte pour
note/barème × 20 × coefficient, la moyenne générale pondère les matières par
leur coefficient, et les notes non chiffrées ou non significatives sont
exclues. Vérifié sur une année archivée : le calcul retombe exactement sur les
moyennes officielles publiées. Celles-ci sont renvoyées à côté une fois la
période close.
## Variables d'environnement
- `SESSION_PATH` — chemin du fichier de session (défaut `~/.config/ecoledirecte-mcp/session.json`)
- `DEVICE_ID_PATH` — chemin de l'identifiant d'appareil (défaut `~/.config/ecoledirecte-mcp/device-id`)
- `DOWNLOAD_DIR` — dossier de téléchargement (défaut `~/.local/share/ecoledirecte-mcp/downloads`)
- `READ_ONLY` — `true` pour désactiver `mark_homework_done` (défaut `false` en local)
- `SESSION_MAX_AGE_MS` — âge au-delà duquel la session est rafraîchie préventivement (défaut 15 min)
Transport HTTP uniquement :
- `MCP_AUTH_TOKEN` — **obligatoire**, le serveur refuse de démarrer sans (`openssl rand -hex 32`)
- `MCP_HTTP_HOST` — adresse d'écoute (défaut `127.0.0.1`, jamais `0.0.0.0`)
- `MCP_HTTP_PORT` — port (défaut `8787`)
- `MCP_ALLOWED_HOSTS` — en-têtes `Host` acceptés, séparés par des virgules (défaut : l'adresse d'écoute)
Le `deviceUUID` généré au premier `login` est stocké séparément dans
`~/.config/ecoledirecte-mcp/device-id`. Ne pas le supprimer entre deux
logins, sous peine de redéclencher le QCM à chaque fois.
## Authentification : ce qu'il faut savoir
École Directe délivre **deux** secrets distincts, tous deux dans
`session.json` :
- `token` — jeton de session court, envoyé en header `X-Token` à chaque
appel de données ; il tourne à chaque login ou re-login ;
- `accessToken` — credential long, lié à l'appareil, seule chose capable de
régénérer un `token` sans le mot de passe.
Les confondre fait échouer **tous** les appels avec `520 "Token invalide !"`.
C'est pour cette raison que l'authentification (login, QCM, re-login) est
implémentée directement dans `src/client/edAuth.ts` plutôt que déléguée à
`@blockshub/blocksdirecte`, dont le module d'auth :
1. ne lit le jeton que dans le corps de la réponse, alors qu'École Directe
le renvoie aussi (parfois uniquement) dans le header `X-Token` ;
2. ne reconnaît que les codes 250 et 505 au re-login — le `526`
« Votre session est invalide ou expirée » tombe dans son chemin de succès
et produit une session vide qui ressemble à une réussite ;
3. écrit sur **stdout**, ce qui corrompt le flux JSON-RPC du transport stdio.
Le reste a suivi : **tous** les appels passent désormais en HTTP direct
(`src/client/edData.ts` pour les notes, devoirs, EDT, vie scolaire, vie de
classe et fil d'actualité ; `messaging.ts`, `documents.ts`, `download.ts`),
et la librairie n'est plus une dépendance. Elle posait trois problèmes :
1. elle jetait le code numérique d'École Directe sur les appels de données,
si bien qu'un jeton expiré ne se devinait qu'à une réponse vide ;
désormais `520`/`525` deviennent une vraie `TokenExpiredError`, et le
rafraîchissement automatique s'applique aussi aux écritures ;
2. elle décodait en base64 **toute** chaîne qui en avait l'air, ce qui
transforme un code de 4 lettres comme `ESP2` en charabia ; seuls les
champs réellement encodés (contenus des devoirs, des séances, de la vie
de classe, des messages) sont décodés ;
3. elle démarrait un `setInterval` impossible à arrêter, qu'il fallait
neutraliser pour qu'un script ponctuel se termine.
`downloader.getStream()`, lui, jetait les en-têtes de réponse : le vrai nom
de fichier — porté par `Content-Disposition` — était perdu et chaque
document atterrissait sur le disque nommé d'après son identifiant numérique.
Le User-Agent `BlocksDirecte/1.0 …` est conservé tel quel : École Directe lie
le jeton au User-Agent qui l'a obtenu, en changer invaliderait les sessions
existantes.
À noter : un téléchargement en échec répond quand même **HTTP 200**. École
Directe met son propre code dans l'en-tête `X-Code` (403 pour un
identifiant inconnu, avec une page d'erreur HTML en guise de contenu), ce
que le code vérifie avant d'écrire quoi que ce soit sur le disque.
## Limitations connues (V1)
**Expiration de session en cours d'utilisation.** La session est rafraîchie
préventivement au-delà de `SESSION_MAX_AGE_MS`, et un appel de données qui
échoue de façon récupérable déclenche un rafraîchissement puis une seule
nouvelle tentative — jamais de boucle. Si le re-login lui-même est refusé
(identifiant d'appareil révoqué, QCM redemandé), il faut relancer `login` ;
le health check le signale (voir plus bas).
**Durée de vie réelle du jeton inconnue.** Les 15 minutes par défaut de
`SESSION_MAX_AGE_MS` sont une valeur prudente, pas une valeur observée. À
calibrer à l'usage (voir « Développement » ci-dessous).
## Hébergement sur le VPS, via Tailscale (V2)
Le transport HTTP est fait pour être joignable **depuis le tailnet et nulle
part ailleurs**. Trois protections se cumulent :
1. Le port n'est publié que sur une adresse explicite. `docker-compose.yml`
exige `PUBLISH_ADDRESS` et refuse de démarrer sans — écrire `8787:8787`
aurait lié `0.0.0.0` sur l'hôte et exposé le serveur à l'internet ouvert.
Pour un déploiement tailnet, mets-y l'IP Tailscale du VPS.
2. Chaque requête `/mcp` doit porter `Authorization: Bearer $MCP_AUTH_TOKEN`.
La comparaison passe par `timingSafeEqual` sur des empreintes SHA-256 :
à temps constant, et sans fuir la longueur du jeton attendu.
3. Protection anti DNS rebinding : le `Host` de la requête doit figurer dans
`MCP_ALLOWED_HOSTS`, sinon 403. Sans elle, une page ouverte dans ton
navigateur pourrait faire pointer son propre domaine vers l'adresse tailnet
et parler au serveur à ta place.
`READ_ONLY` vaut **`true` par défaut** sur ce transport (contre `false` en
stdio) : il est joignable depuis d'autres machines, pas seulement par toi à
ton clavier. `mark_homework_done` disparaît alors de la liste des outils.
### Mise en route
cp .env.example .env # renseigner MCP_AUTH_TOKEN et PUBLISH_ADDRESS
docker compose build # build natif arm64 sur le VPS Ampere
docker compose run --rm -it ecoledirecte-mcp login
docker compose up -d
Le `login` se fait bien **avant** le `up`, et via `run --rm -it` pour avoir un
terminal : il faut répondre au QCM. Session et identifiant d'appareil sont
écrits sur le volume `session`, donc conservés entre deux recréations du
conteneur — c'est pourquoi `DEVICE_ID_PATH` existe : sans lui l'identifiant
serait recréé à chaque fois et École Directe redemanderait le QCM.
Le conteneur écoute sur `0.0.0.0` **à l'intérieur** de son espace réseau, ce
qui est correct : l'isolation vient de la publication du port sur la seule IP
Tailscale.
`/health` répond sans jeton, et volontairement sans rien dire du compte. Il
ne se contente pas de dire que le processus tourne : toutes les 15 minutes
(`HEALTH_CHECK_INTERVAL_MS`), le serveur fait un vrai appel léger, avec
rafraîchissement du jeton comme pour un outil. Après deux échecs d'affilée —
un seul serait peut-être un incident réseau —, `/health` répond **503** avec
un motif sommaire :
{"status":"failing","sessionExists":true,
"session":{"status":"failing","reason":"auth_required",
"since":"…","checkedAt":"…"}}
`reason` vaut `auth_required` (QCM redemandé, appareil révoqué : relancer
`login`), `school_unavailable` (maintenance École Directe), `no_session` ou
`error`. Le `HEALTHCHECK` Docker passe alors le conteneur en `unhealthy`, et
la supervision (Uptime Kuma, Gatus) alerte sur le 503 via le tailnet —
`http://<ip-tailscale>:8789/health`, en n'acceptant que 2xx. Le serveur
n'envoie lui-même aucune notification.
### Connecter un client
claude mcp add --transport http ecoledirecte http://<ip-tailscale>:8787/mcp \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
Le transport est **sans état** (pas de `mcp-session-id`) : le serveur est
mono-utilisateur et ne pousse rien vers le client, donc il n'y a aucun cycle
de vie de session à gérer côté serveur.
## Exposition publique, pour un connecteur Claude.ai
Un connecteur personnalisé Claude.ai est appelé par **les serveurs
d'Anthropic**, pas par ton appareil : la doc exige un serveur « reachable over
the public internet from Anthropic's IP ranges ». **Tailscale ne peut donc pas
servir ce cas** — même installé sur tous tes appareils, l'endpoint resterait
injoignable pour Anthropic. Une exposition publique est la seule voie.
### Ce qui limite les dégâts
Anthropic publie sa plage de sortie, `160.79.104.0/21`. En l'allowlistant dans
Nginx Proxy Manager, le domaine est public dans le DNS mais seule
l'infrastructure d'Anthropic peut parler à `/mcp`, `/token`, `/register` et
aux métadonnées : un scanner se fait refouler avant même d'atteindre
l'authentification.
Trois chemins font exception : `/authorize` et `/oauth/consent`, ainsi que
les liens de téléchargement `/downloads/<jeton>`, sont ouverts par **ton
navigateur**, pas par Anthropic. Un `deny all` global les
bloquerait : l'autorisation échouerait sur un 403, et aucun lien ne
s'ouvrirait. Ils restent donc ouverts, protégés par la phrase secrète (cinq
essais par demande) et la limitation de débit du routeur OAuth pour les
premiers, par un jeton aléatoire qui expire au bout d'une heure pour les liens. Onglet **Advanced** du Proxy Host :
```nginx
# Streamable HTTP peut ouvrir un flux SSE sur GET /mcp :
proxy_buffering off;
proxy_read_timeout 3600s;
# Ouverts par ton navigateur : autorisation OAuth et liens de téléchargement.
location ~ ^/(authorize|oauth/consent|downloads/[A-Za-z0-9_-]+)$ {
include conf.d/include/proxy.conf;
}
# Tout le reste : Anthropic uniquement. Définir `location /` ici fait que
# NPM n'émet pas le sien.
location / {
allow 160.79.104.0/21;
deny all;
include conf.d/include/proxy.conf;
}
```
Plus un certificat Let's Encrypt avec *Force SSL*. Garde `READ_ONLY=true`.
### Authentification : OAuth
Claude ne sait pas envoyer un en-tête fixe sur un connecteur personnalisé,
sauf via `static_headers`, en beta et réservé à un administrateur
d'organisation. Le serveur implémente donc un **serveur d'autorisation OAuth
2.0** complet : métadonnées RFC 8414 et RFC 9728, enregistrement dynamique de
client (RFC 7591), PKCE S256, rotation des refresh tokens, `401` avec
`WWW-Authenticate` pointant vers les métadonnées de ressource.
Comme le serveur ne dessert qu'un seul compte, l'« utilisateur » OAuth est
toujours toi : le consentement est un écran qui demande
`MCP_OAUTH_PASSPHRASE`. Anthropic impose un humain dans la boucle — un flux
purement machine-à-machine n'est pas accepté. Cinq mauvaises réponses brûlent
la demande en cours.
Clients enregistrés et jetons sont persistés sur le volume, en **empreintes
SHA-256** : le serveur n'a jamais besoin de relire un jeton, seulement de
vérifier une correspondance, donc une fuite du fichier ne donne aucun
identifiant utilisable. La persistance évite aussi que le connecteur casse à
chaque `docker compose up`.
### Mise en route
Dans `.env` :
MCP_PUBLIC_URL=https://ed.ton-domaine.fr/mcp
MCP_OAUTH_PASSPHRASE=<une phrase longue>
MCP_ALLOWED_HOSTS=ed.ton-domaine.fr
READ_ONLY=true
Si NPM tourne **en conteneur**, aucun port n'est publié sur l'hôte : le proxy
joint le service par son nom sur un réseau Docker partagé.
docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d
avec `NPM_NETWORK` réglé sur le réseau de NPM (`docker network ls`), et dans
NPM : *Scheme* `http`, *Forward Hostname* `ecoledirecte-mcp`, *Forward Port*
`8787`. Si NPM tourne sur l'hôte, garde `docker-compose.yml` seul avec
`PUBLISH_ADDRESS=127.0.0.1`.
Pour servir **les deux** à la fois — tailnet avec le jeton fixe pour tes
machines, et connecteur Claude.ai public via NPM en conteneur —
`docker-compose.npm-and-tailnet.yml` garde le port publié sur
`PUBLISH_ADDRESS` (l'IP Tailscale) et rejoint en plus le réseau de NPM.
Mettre dans `.env` :
COMPOSE_FILE=docker-compose.yml:docker-compose.npm-and-tailnet.yml
MCP_ALLOWED_HOSTS=<ip-tailscale>:<port>,ed.ton-domaine.fr
`MCP_HTTP_PORT` ne règle que le port **côté hôte** ; dans le conteneur, le
serveur écoute toujours sur 8787, qui est donc le *Forward Port* à donner à
NPM.
Ensuite, dans Claude.ai : Paramètres → Connecteurs → connecteur personnalisé,
URL `https://ed.ton-domaine.fr/mcp`. Claude découvre le serveur
d'autorisation, s'enregistre, et t'affiche l'écran de consentement où tu
saisis la phrase secrète.
## Statut
stdio (local), HTTP sur tailnet, et HTTP public avec OAuth pour un connecteur
Claude.ai sont faits. Un outil d'envoi de messages reste volontairement non
implémenté.
La messagerie est en **lecture seule** : lister et lire. Envoyer, répondre
et transférer ne sont pas implémentés — ce sont des écritures visibles par
des tiers (professeurs, administration), à n'ajouter que délibérément.
## Développement
npm test # tests unitaires — aucun appel réseau réel
npm run typecheck # vérifie aussi test/ et scripts/
npm run build
npm run smoke-test # vérification manuelle contre le vrai compte
`smoke-test` utilise la session déjà enregistrée par `login`, n'a besoin
d'aucun identifiant, et n'est jamais lancé en CI. Il appelle chaque outil de
lecture à la suite et continue même si l'un échoue, pour montrer d'un coup
l'état réel de tous les endpoints.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues