Skip to main content
Glama
Mathr81

ecole-directe-mcp

by Mathr81

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.

Related MCP server: EduPage MCP Server

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 :

# 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Enables AI agents to query and manage EduPage school accounts, including timetables, grades, homework, meals, messages, multi-school discovery, role-aware student switching, and 2FA login.
    31
    1
    MIT
  • A
    license
    C
    quality
    B
    maintenance
    Enables an agent to read and act on a Norwegian school parent portal through its unofficial API, covering message threads and attachments, timetables, absences, consent forms, news and scheduling events. Tokens are stored locally, and raw GET tools allow further endpoint exploration.
    12
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI assistants to retrieve and work with Moscow Electronic School data, including schedules, homework, grades, rankings, school info, meals, passes, olympiads, and portfolio, via authenticated MCP tools.
    MIT