From 56dd1f89bdc88b52786fe8a0149a0cb3692c1fac Mon Sep 17 00:00:00 2001 From: Olivier Date: Wed, 15 Jul 2026 22:59:44 +0200 Subject: [PATCH] Update MCP server --- docker-compose.yml | 17 +- mcp-server/.dockerignore | 1 - mcp-server/Dockerfile | 4 +- mcp-server/README.md | 278 +++++++++-------------- mcp-server/index.js | 245 -------------------- mcp-server/package-lock.json | 3 - mcp-server/package.json | 13 +- mcp-server/{http-server.js => server.js} | 105 +++++---- mcp-server/tools.js | 166 +++++++++++++- 9 files changed, 341 insertions(+), 491 deletions(-) delete mode 100644 mcp-server/index.js rename mcp-server/{http-server.js => server.js} (60%) diff --git a/docker-compose.yml b/docker-compose.yml index a753a4a..c062f69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,12 +49,13 @@ services: - internal - backend # réseau Traefik - # Serveur MCP distant (Phase 5) — accessible publiquement sur un - # sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au - # reste de l'app, ce service est volontairement ouvert à des utilisateurs - # distants qui ne peuvent pas déployer le serveur MCP local. La clé API - # (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE - # barrière d'accès — voir mcp-server/http-server.js. + # Serveur MCP (Phase 5) — même image/code que le serveur utilisé en + # développement local (mcp-server/server.js, npm run dev), déployé ici + # accessible publiquement sur un sous-domaine dédié, SANS le middleware + # ipwhitelist-all : contrairement au reste de l'app, ce service est + # volontairement ouvert à des utilisateurs distants qui ne peuvent pas + # faire tourner le serveur en local. La clé API (en-tête X-API-Key, propre + # à chaque utilisateur) est donc la SEULE barrière d'accès. crowdlending-mcp: build: context: ./mcp-server @@ -66,6 +67,10 @@ services: PORT: 4100 CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1 MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost + # Explicitement désactivé : ce serveur sert des utilisateurs distants + # non maîtrisés, l'outil de lecture d'URL arbitraire (SSRF) reste + # réservé au développement local. Ne pas passer à true ici. + MCP_ENABLE_FETCH_URL: "false" depends_on: crowdlending-backend: condition: service_healthy diff --git a/mcp-server/.dockerignore b/mcp-server/.dockerignore index e3ee72d..2519638 100644 --- a/mcp-server/.dockerignore +++ b/mcp-server/.dockerignore @@ -2,4 +2,3 @@ node_modules npm-debug.log *.log README.md -index.js diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 95e1aa0..67c3edc 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -5,11 +5,11 @@ ENV NODE_ENV=production COPY package.json package-lock.json* ./ RUN npm install --omit=dev -COPY tools.js http-server.js ./ +COPY tools.js server.js ./ EXPOSE 4100 HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" -CMD ["node", "http-server.js"] +CMD ["node", "server.js"] diff --git a/mcp-server/README.md b/mcp-server/README.md index e7b411c..ae32460 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,28 +1,20 @@ # crowdlending-mcp-server -Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule -les données d'un investisseur (investissements, remboursements, -dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code, -ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1` -du backend. +Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de +crowdlending. Il expose en lecture seule les données d'un investisseur +(investissements, remboursements, dépôts/retraits, dashboard) à un client +MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en +s'appuyant sur l'API publique `/api/v1` du backend. Il ne fait aucune écriture : toutes les modifications restent à faire dans l'app web. -Deux modes, deux fichiers : - -- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude - Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en - local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web). -- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par - l'administrateur de l'instance) sur `mcp.crowdlending.croguennec.net`, - accessible à n'importe quel utilisateur distant sans rien installer : - juste une URL + sa clé API personnelle à coller dans son client MCP. - N'inclut PAS `crowdlending_fetch_url` (voir plus bas). - -Les deux partagent le même code pour les 6 outils de lecture de données -(`tools.js`) — leur comportement ne peut donc pas diverger entre les deux -modes. +**Un seul serveur, un seul fichier (`server.js`)**, utilisé aussi bien en +développement local (`npm run dev`, comme le backend et le frontend) qu'en +production (service Docker `crowdlending-mcp`, derrière Traefik sur +`mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les +deux — seule la configuration change (quelle API cibler, quels outils +activer). ## Prérequis @@ -31,130 +23,28 @@ modes. - Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre instance en production) -## Installation +## Développement local + +Comme pour `backend/` et `frontend/` : ``` cd mcp-server npm install +npm run dev ``` -## Configuration +`npm run dev` (comme `backend`) relance automatiquement le serveur à chaque +modification de fichier (`node --watch`). Par défaut, il écoute sur +`http://localhost:4100` et cible l'API locale (`http://localhost:4000/api/v1`). -Trois variables d'environnement : - -| Variable | Obligatoire | Défaut | Exemple | -|---|---|---|---| -| `CROWDLENDING_API_KEY` | oui | — | `clk_live_...` | -| `CROWDLENDING_API_URL` | non | `http://localhost:4000/api/v1` | `https://mon-domaine.fr/api/v1` | -| `CROWDLENDING_LABEL` | non | — | `dev`, `prod` | - -`CROWDLENDING_LABEL` sert uniquement à distinguer plusieurs instances -connectées en même temps (voir [Faire tourner dev et prod en même -temps](#faire-tourner-dev-et-prod-en-même-temps)) : il apparaît dans le nom -du serveur, dans le titre de chaque outil (`[DEV]` / `[PROD]`) et dans la -description (avec l'URL API ciblée), pour que l'agent — et vous — sachiez -toujours quel environnement est interrogé. - -## Utiliser avec Claude Desktop - -Localisez le fichier de config Claude Desktop — le chemin diffère selon la -provenance de l'installation Windows : - -- **Installeur classique** (téléchargé depuis claude.ai) : - `%APPDATA%\Claude\claude_desktop_config.json` -- **Microsoft Store** : Windows redirige `%APPDATA%` vers un dossier virtualisé - propre à l'app — - `%LOCALAPPDATA%\Packages\Claude_\LocalCache\Roaming\Claude\claude_desktop_config.json` - (le `` est un identifiant généré, propre à votre installation). - -Le plus fiable dans les deux cas : Réglages → Développeur → Serveurs MCP -locaux → **Modifier la config**, qui ouvre directement le bon fichier quelle -que soit la provenance de l'installation. - -Ajoutez une entrée dans `mcpServers` : - -```json -{ - "mcpServers": { - "crowdlending": { - "command": "node", - "args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], - "env": { - "CROWDLENDING_API_KEY": "clk_live_...", - "CROWDLENDING_API_URL": "http://localhost:4000/api/v1" - } - } - } -} -``` - -Redémarrez Claude Desktop. L'icône 🔌 (ou le menu des outils MCP) doit -afficher les 7 outils `crowdlending_*` ci-dessous. - -Pour pointer vers votre instance de production plutôt que le backend local, -changez uniquement `CROWDLENDING_API_URL` (et utilisez une clé API générée -sur cette instance). - -## Faire tourner dev et prod en même temps - -Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il -suffit de déclarer deux entrées avec des clés distinctes dans `mcpServers`. -Chaque outil est alors automatiquement rattaché à son serveur d'origine — -pas de collision technique possible entre les deux, même si les noms -d'outils (`crowdlending_get_dashboard`, etc.) sont identiques des deux côtés. +Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP +locaux → **Modifier la config**, puis ajoutez dans `mcpServers` : ```json { "mcpServers": { "crowdlending-dev": { - "command": "node", - "args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], - "env": { - "CROWDLENDING_API_KEY": "clk_live_...", - "CROWDLENDING_API_URL": "http://localhost:4000/api/v1", - "CROWDLENDING_LABEL": "dev" - } - }, - "crowdlending-prod": { - "command": "node", - "args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], - "env": { - "CROWDLENDING_API_KEY": "clk_live_...", - "CROWDLENDING_API_URL": "https://mon-domaine.fr/api/v1", - "CROWDLENDING_LABEL": "prod" - } - } - } -} -``` - -Utilisez deux clés API différentes (une par instance, générées séparément -sur chaque backend) : ça permet de révoquer l'une sans affecter l'autre, et -c'est cohérent avec le principe d'une clé par usage. - -Avec `CROWDLENDING_LABEL` renseigné, chaque outil affiche son environnement -dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description -se termine par la source exacte interrogée — de quoi lever toute ambiguïté -si vous demandez « mon encours en prod » vs « mon encours en dev ». - -## Serveur distant — pour les utilisateurs sans installation locale - -Si vous n'êtes pas sur la machine qui héberge ce dépôt (pas de Node.js, pas -envie de cloner le repo), vous pouvez vous connecter directement au serveur -MCP distant déployé sur `https://mcp.crowdlending.croguennec.net` — aucune -installation nécessaire, juste votre clé API personnelle. - -**1. Créer votre clé API** — dans l'app, Mon compte → Clés API → Nouvelle -clé (elle ne sera affichée qu'une seule fois, copiez-la). - -**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur → -Serveurs MCP locaux → Modifier la config, puis ajoutez : - -```json -{ - "mcpServers": { - "crowdlending-distant": { - "url": "https://mcp.crowdlending.croguennec.net/mcp", + "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "clk_live_..." } @@ -163,52 +53,95 @@ Serveurs MCP locaux → Modifier la config, puis ajoutez : } ``` -**3. Redémarrez Claude Desktop.** Les 6 outils `crowdlending_*` doivent -apparaître (sans `crowdlending_fetch_url`, réservé au serveur local). +Redémarrez Claude Desktop. Les outils `crowdlending_*` doivent apparaître +(voir la liste plus bas — `crowdlending_fetch_url` en plus si activé, voir +Configuration). -### Différences avec le serveur local +## Configuration -- **Pas de `crowdlending_fetch_url`** : lire une page web arbitraire pour un - utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice - réel pour ce cas d'usage. Cet outil reste réservé au serveur local. -- **Authentification par requête, pas par process** : le serveur local a une - clé API fixée une fois pour toutes via une variable d'environnement — un - process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en - parallèle : chaque session est créée à partir de la clé API envoyée dans - l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette - session uniquement. Deux utilisateurs ne partagent jamais de données. -- **Exposé publiquement, sans la liste blanche d'IP** qui protège le reste de - l'app (`ipwhitelist-all`) : la clé API est la seule barrière d'accès. - Traitez-la comme un mot de passe — ne la partagez pas, révoquez-la - immédiatement en cas de doute (Mon compte → Clés API). -- **Limite de débit** : 60 requêtes/minute par adresse IP, au-delà l'API - répond `429`. -- **Sessions** : une session inactive plus de 30 minutes est fermée côté - serveur (mémoire uniquement, aucune persistance) ; votre client MCP en - recréera une automatiquement à la prochaine requête. +Variables d'environnement, toutes optionnelles sauf pour un déploiement +distant réel (en local, les valeurs par défaut conviennent) : -### Déploiement (administrateur de l'instance) +| Variable | Défaut | Description | +|---|---|---| +| `PORT` | `4100` | Port d'écoute HTTP | +| `CROWDLENDING_API_URL` | `http://localhost:4000/api/v1` | API v1 ciblée | +| `MCP_LABEL` | — | Étiquette d'environnement (`dev`, `prod`...) — voir plus bas | +| `MCP_ENABLE_FETCH_URL` | `false` | Active l'outil `crowdlending_fetch_url` (voir plus bas) | +| `MCP_ALLOWED_HOSTS` | `mcp.crowdlending.croguennec.net,localhost` | En-têtes `Host` acceptés (protection anti DNS-rebinding) | -Le service `crowdlending-mcp` du `docker-compose.yml` construit et lance -`http-server.js`, exposé via Traefik sur `mcp.crowdlending.croguennec.net` -(certificat TLS automatique, même resolver que le reste de l'app). Un -enregistrement DNS pour ce sous-domaine, pointant vers la même IP que +Aucune clé API fixe n'est configurée côté serveur, contrairement à une +ancienne version qui utilisait le transport stdio : chaque session MCP lit +sa propre clé dans l'en-tête `X-API-Key` de la requête qui l'initialise (le +bloc `headers` de la config Claude Desktop ci-dessus). Un même process peut +donc servir plusieurs utilisateurs/sessions en parallèle sans jamais mélanger +leurs données — voir le déploiement distant plus bas. + +## Faire tourner dev et prod en même temps + +Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il +suffit de déclarer deux entrées dans `mcpServers`, une par URL. Chaque outil +est automatiquement rattaché à son serveur d'origine — pas de collision +possible, même si les noms d'outils sont identiques des deux côtés. + +```json +{ + "mcpServers": { + "crowdlending-dev": { + "url": "http://localhost:4100/mcp", + "headers": { "X-API-Key": "clk_live_..." } + }, + "crowdlending-prod": { + "url": "https://mcp.crowdlending.croguennec.net/mcp", + "headers": { "X-API-Key": "clk_live_..." } + } + } +} +``` + +Utilisez deux clés API différentes (une par instance) : ça permet de +révoquer l'une sans affecter l'autre. Réglez `MCP_LABEL=dev` (côté serveur +local, dans votre `.env` ou variable d'environnement au lancement) pour que +le titre de chaque outil affiche `[DEV]` et lève toute ambiguïté — le +serveur distant est déjà configuré avec `MCP_LABEL` correspondant en +production. + +## Déploiement en production (Docker) + +Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit +et lance ce même `server.js`, exposé via Traefik sur +`mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement +au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` : +il est volontairement accessible depuis internet, pour des utilisateurs +distants qui ne peuvent pas faire tourner le serveur en local — la clé API +est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et +révoquez-la immédiatement en cas de doute (Mon compte → Clés API). + +Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que `crowdlending.croguennec.net`, est nécessaire avant le premier déploiement. -Variables d'environnement du service : `CROWDLENDING_API_URL` (URL interne -du backend sur le réseau Docker, déjà configurée) et `MCP_ALLOWED_HOSTS` -(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit -correspondre exactement au(x) nom(s) de domaine exposé(s)). + +`MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml) +: cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour +un usage perso où vous seul détenez la clé, mais risqué (SSRF) face à des +utilisateurs distants non maîtrisés. + +Autres protections : limite de 60 requêtes/minute par IP (au-delà, `429`), +et fermeture automatique des sessions inactives depuis plus de 30 minutes +(mémoire uniquement, aucune persistance). ## Tester sans Claude Desktop Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet -de lister et appeler les outils depuis une interface web, sans configurer de -client : +de lister et appeler les outils depuis une interface web : ``` -CROWDLENDING_API_KEY=clk_live_... npm run inspect +npm run inspect ``` +Dans l'interface, choisissez le transport **Streamable HTTP**, entrez +l'URL (`http://localhost:4100/mcp` en dev) et ajoutez l'en-tête +`X-API-Key` avec votre clé. + ## Outils exposés Tous en lecture seule (`readOnlyHint: true`) : @@ -221,10 +154,14 @@ Tous en lecture seule (`readOnlyHint: true`) : | `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements | | `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période | | `crowdlending_list_depots_retraits` | Historique des mouvements de cash | -| `crowdlending_fetch_url` | *(serveur local uniquement)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous | +| `crowdlending_fetch_url` | *(actif seulement si `MCP_ENABLE_FETCH_URL=true`)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous | ## Lire une annonce de projet (`crowdlending_fetch_url`) +Désactivé par défaut — à activer avec `MCP_ENABLE_FETCH_URL=true`, +typiquement en développement local uniquement (voir Configuration et +Sécurité plus haut). + Cet outil récupère une page web et en extrait le contenu lisible (titre + texte principal) via [Readability](https://github.com/mozilla/readability), la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux @@ -249,10 +186,17 @@ Garde-fous : ## Dépannage -- **`ERREUR : CROWDLENDING_API_KEY est requise`** — la variable d'env n'est - pas transmise. Vérifiez la section `env` de votre config MCP. +- **`En-tête X-API-Key manquant`** — vérifiez la section `headers` de votre + config Claude Desktop. - **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte → Clés API et mettez à jour la config. - **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou `CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le suffixe `/api/v1`). +- **`Invalid Host` (403)** — l'en-tête `Host` de la requête ne correspond à + aucune valeur de `MCP_ALLOWED_HOSTS`. En local, vérifiez que vous appelez + bien `localhost:4100` (pas `127.0.0.1:4100`, absent de la liste par + défaut — ajoutez-le à `MCP_ALLOWED_HOSTS` si besoin). +- **Session `404` après une longue pause** — la session a expiré après 30 + minutes d'inactivité, votre client MCP doit s'y reconnecter (généralement + automatique). diff --git a/mcp-server/index.js b/mcp-server/index.js deleted file mode 100644 index dd0c582..0000000 --- a/mcp-server/index.js +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env node -/** - * Serveur MCP local (stdio) — Crowdlending Tracker - * - * Expose en lecture seule le portefeuille de crowdlending (investissements, - * remboursements, dépôts/retraits, dashboard) à un client MCP (Claude Desktop, - * Claude Code...) via l'API publique /api/v1 du backend. - * - * Authentification : clé API générée dans l'app (Mon compte → Clés API), - * transmise ici via la variable d'environnement CROWDLENDING_API_KEY. - * La clé est scopée à un seul investisseur — ce serveur ne voit donc que - * les données de cet investisseur, jamais l'ensemble du portefeuille famille. - * - * IMPORTANT : ce process communique avec le client MCP via stdout (protocole - * JSON-RPC). Ne jamais utiliser console.log ici — uniquement console.error - * pour les logs de diagnostic (redirigés vers stderr, invisibles du protocole). - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import { JSDOM } from 'jsdom'; -import { Readability } from '@mozilla/readability'; -import { registerDataTools, toolError } from './tools.js'; - -const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, ''); -const API_KEY = process.env.CROWDLENDING_API_KEY; - -// Étiquette facultative pour distinguer plusieurs instances connectées en -// même temps à Claude Desktop (ex. une pour le backend de dev, une pour la -// prod). Sans elle, on peut toujours faire tourner les deux : Claude Desktop -// namespace déjà chaque outil par le nom du serveur (clé du bloc mcpServers -// dans claude_desktop_config.json), donc pas de collision technique. Le -// LABEL sert surtout à ce que l'agent (et vous) voyiez clairement, dans le -// titre et la description de chaque outil, quel environnement est visé. -const LABEL = (process.env.CROWDLENDING_LABEL || '').trim(); - -if (!API_KEY) { - console.error('ERREUR : la variable d\'environnement CROWDLENDING_API_KEY est requise.'); - console.error('Générez une clé dans l\'app : Mon compte → Clés API.'); - process.exit(1); -} - -/* ── Client API partagé ───────────────────────────────────────────────── */ -async function apiGet(path, params) { - const url = new URL(API_BASE + path); - if (params) { - for (const [k, v] of Object.entries(params)) { - if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); - } - } - - let res; - try { - res = await fetch(url, { - headers: { 'X-API-Key': API_KEY, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), - }); - } catch (e) { - throw new Error(`Impossible de joindre l'API (${API_BASE}) : ${e.message}`); - } - - const text = await res.text(); - let body; - try { body = text ? JSON.parse(text) : null; } catch { body = text; } - - if (!res.ok) { - const msg = (body && body.error) || res.statusText || 'Requête échouée'; - if (res.status === 401) throw new Error(`Clé API invalide ou révoquée (${msg}). Générez-en une nouvelle dans Mon compte → Clés API.`); - if (res.status === 404) throw new Error(`Ressource introuvable : ${msg}`); - throw new Error(`Erreur API (${res.status}) : ${msg}`); - } - return body; -} - -/* ── Serveur MCP ──────────────────────────────────────────────────────── */ -const server = new McpServer({ - name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''), - version: '0.1.0', -}); - -const READ_ONLY_ANNOTATIONS = { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, -}; - -/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */ -const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title; - -/** Ajoute la source (URL API + libellé) en fin de description, pour que - * l'agent distingue sans ambiguïté deux instances connectées en parallèle. */ -const withSource = (description) => - `${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`; - -// Les 6 outils de lecture de données (investisseur, dashboard, investissements, -// remboursements, dépôts/retraits) sont définis dans tools.js, partagés avec -// le serveur HTTP distant — voir ce fichier pour leur documentation. -registerDataTools(server, { apiGet, withLabel, withSource }); - -/* ── Outil fetch_url (Phase 3) ──────────────────────────────────────────── - Récupère une page web (annonce de projet sur une plateforme, par ex.) et en - extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via - Readability — la même librairie que le mode lecture de Firefox. L'outil ne - fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/ - échéance côté serveur) : il fournit le texte propre, et c'est à l'agent - d'en extraire les informations pertinentes dans la conversation, puis de - les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du - serveur : aucune écriture, la création d'un investissement reste un geste - manuel dans l'app. ────────────────────────────────────────────────────── */ - -const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue -const FETCH_TIMEOUT_MS = 15000; -const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.1; +local-tool)'; - -/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire - * en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur - * (y compris son propre backend). Vérif sur le nom d'hôte littéral — ne - * résout pas le DNS, donc pas une protection anti-rebinding DNS complète, - * mais bloque les cas évidents (localhost, IP privées écrites en clair). */ -function isBlockedHost(hostname) { - const h = hostname.toLowerCase(); - if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true; - const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (ipv4) { - const [a, b] = ipv4.slice(1).map(Number); - if (a === 127 || a === 10 || a === 0) return true; - if (a === 192 && b === 168) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 169 && b === 254) return true; - } - return false; -} - -server.registerTool( - 'crowdlending_fetch_url', - { - title: 'Lire une page web', - description: - "Récupère une page web (ex. annonce d'un projet sur une plateforme de " + - "crowdlending) et en extrait le contenu lisible (titre + texte principal, " + - "débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " + - "dans l'app — l'agent doit extraire lui-même les informations utiles du " + - "texte retourné (taux, montant, durée, émetteur...) et les proposer à " + - "l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " + - "(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " + - `${CHARACTER_LIMIT} caractères sur les pages très longues.`, - inputSchema: { - url: z.string().url().describe("URL de la page à lire (http/https uniquement)"), - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - }, - async ({ url }) => { - try { - let parsed; - try { parsed = new URL(url); } - catch { throw new Error(`URL invalide : ${url}`); } - - if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`); - } - if (isBlockedHost(parsed.hostname)) { - throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`); - } - - let res; - try { - res = await fetch(parsed, { - headers: { - 'User-Agent': FETCH_USER_AGENT, - 'Accept': 'text/html,application/xhtml+xml', - 'Accept-Language': 'fr-FR,fr;q=0.9', - }, - redirect: 'follow', - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - } catch (e) { - // Node/undici masque souvent la vraie cause derrière un message générique - // "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est - // dans e.cause, à remonter explicitement pour un diagnostic utile. - const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : ''; - throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`); - } - - if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim()); - - const contentType = res.headers.get('content-type') || ''; - if (!contentType.includes('html')) { - throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`); - } - - const html = await res.text(); - const dom = new JSDOM(html, { url: parsed.toString() }); - const article = new Readability(dom.window.document).parse(); - - const title = article?.title || dom.window.document.title || null; - let text = (article?.textContent || dom.window.document.body?.textContent || '') - .replace(/[ \t]+/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim(); - - const fullLength = text.length; - let truncated = false; - if (text.length > CHARACTER_LIMIT) { - text = text.slice(0, CHARACTER_LIMIT); - truncated = true; - } - - if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page."); - - const output = { - url: parsed.toString(), - title, - site_name: article?.siteName || null, - excerpt: article?.excerpt || null, - text, - length: fullLength, - truncated, - }; - - return { - content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], - structuredContent: output, - }; - } catch (e) { return toolError(e); } - }, -); - -/* ── Démarrage ────────────────────────────────────────────────────────── */ -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error(`[crowdlending-mcp-server${LABEL ? `-${LABEL}` : ''}] connecté — API cible : ${API_BASE}`); -} - -main().catch((e) => { - console.error('[crowdlending-mcp-server] erreur fatale :', e); - process.exit(1); -}); diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 59c6985..c710259 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -15,9 +15,6 @@ "jsdom": "^29.1.1", "zod": "^3.25.0" }, - "bin": { - "crowdlending-mcp-server": "index.js" - }, "engines": { "node": ">=18" } diff --git a/mcp-server/package.json b/mcp-server/package.json index 7001d29..d3642c2 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,16 +1,13 @@ { "name": "crowdlending-mcp-server", "version": "0.2.0", - "description": "Serveur MCP pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Deux modes : local (stdio, index.js) et distant (HTTP, http-server.js).", + "description": "Serveur MCP (HTTP) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Même serveur en développement local et en production distante.", "type": "module", - "main": "index.js", - "bin": { - "crowdlending-mcp-server": "./index.js" - }, + "main": "server.js", "scripts": { - "start": "node index.js", - "start:http": "node http-server.js", - "inspect": "npx @modelcontextprotocol/inspector node index.js" + "start": "node server.js", + "dev": "node --watch server.js", + "inspect": "npx @modelcontextprotocol/inspector" }, "engines": { "node": ">=18" diff --git a/mcp-server/http-server.js b/mcp-server/server.js similarity index 60% rename from mcp-server/http-server.js rename to mcp-server/server.js index 75aa0c1..641e973 100644 --- a/mcp-server/http-server.js +++ b/mcp-server/server.js @@ -1,33 +1,26 @@ #!/usr/bin/env node /** - * Serveur MCP distant (HTTP, Streamable HTTP transport) — Crowdlending Tracker + * Serveur MCP (HTTP, Streamable HTTP transport) — Crowdlending Tracker * - * Variante "réseau" du serveur local stdio (index.js) : mêmes outils de - * lecture (voir tools.js), mais accessible via une URL publique plutôt que - * comme process enfant sur la machine de l'utilisateur. Pensé pour les - * utilisateurs distants qui ne peuvent/veulent pas installer Node.js et - * cloner ce dépôt en local — ils n'ont qu'à ajouter une URL + leur clé API - * personnelle dans la config de leur client MCP (Claude Desktop, etc.). + * Point d'entrée unique, que ce soit en développement local (`npm run dev`, + * comme le backend et le frontend) ou déployé à distance en production + * (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du + * docker-compose). Un seul modèle de transport, un seul fichier à + * maintenir : les outils eux-mêmes vivent dans tools.js. * - * DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est - * lancé une fois par utilisateur, avec UNE clé API fixée par variable - * d'environnement pour toute la durée du process. Ici, un seul process sert - * potentiellement PLUSIEURS utilisateurs distants en parallèle — il n'y a - * donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée - * à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête - * HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des - * outils de CETTE session uniquement (voir createSession ci-dessous). Deux - * utilisateurs distants ne partagent jamais d'état ni de données. + * AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à + * l'ancien serveur stdio. Un même process peut servir plusieurs + * utilisateurs/sessions en parallèle (typiquement un seul en dev local, + * potentiellement plusieurs en déploiement distant) — chaque session MCP est + * initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de + * la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure + * des outils de CETTE session uniquement (voir createSession). Deux sessions + * ne partagent jamais d'état ni de données. * - * `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le - * détail) : réservé au serveur local, pour limiter le risque SSRF envers - * des utilisateurs tiers non maîtrisés. - * - * Sécurité réseau : ce service est prévu pour être exposé directement sur - * internet (sous-domaine dédié, sans la liste blanche d'IP qui protège le - * reste de l'app) — la clé API est donc la SEULE barrière. Voir le - * middleware `requireApiKeyHeader` et la validation du Host (protection - * anti DNS-rebinding) plus bas. + * Connexion depuis Claude Desktop (dev comme prod) : + * { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } } + * (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la + * prod). Voir README.md pour le détail. */ import { randomUUID } from 'node:crypto'; @@ -36,28 +29,49 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import rateLimit from 'express-rate-limit'; -import { registerDataTools, toolError } from './tools.js'; +import { registerDataTools, registerFetchUrlTool } from './tools.js'; const PORT = Number(process.env.PORT || 4100); -const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, ''); -// Nom(s) d'hôte public(s) attendus dans l'en-tête Host — protection anti -// DNS-rebinding. Séparés par des virgules si plusieurs (ex. dev + prod). -// `localhost` est inclus par défaut pour que le HEALTHCHECK Docker (qui -// interroge http://localhost:4100/health depuis l'intérieur du container) -// ne soit pas lui-même bloqué par ce middleware — sans risque côté externe, -// puisque Traefik ne route déjà vers ce service que les requêtes portant le -// Host public configuré sur son router (une requête Host: localhost envoyée -// depuis l'extérieur n'atteint jamais ce container). +const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, ''); + +// Étiquette facultative pour distinguer plusieurs instances connectées en +// même temps à Claude Desktop (ex. dev local + prod distante). Affiche +// "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin +// de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio. +const LABEL = (process.env.MCP_LABEL || '').trim(); + +// Désactivé par défaut : cet outil lit une URL arbitraire fournie par +// l'appelant, sûr pour un usage perso (vous seul avez la clé API et +// n'atteignez que ce process) mais risqué si le serveur est exposé à des +// tiers non maîtrisés (SSRF). À activer explicitement pour le développement +// local — jamais sur le déploiement distant public (voir docker-compose.yml). +const ENABLE_FETCH_URL = ['1', 'true', 'yes'].includes((process.env.MCP_ENABLE_FETCH_URL || '').toLowerCase()); + +// Nom(s) d'hôte attendus dans l'en-tête Host — protection anti DNS-rebinding. +// `localhost` est inclus par défaut : nécessaire en développement local +// (connexion directe à localhost:4100) et pour que le HEALTHCHECK Docker +// (qui interroge http://localhost:4100/health depuis l'intérieur du +// container) ne soit pas lui-même bloqué. Sans risque côté externe en +// production : Traefik ne route vers ce service que les requêtes portant le +// Host public configuré sur son router. const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost') .split(',').map((h) => h.trim()).filter(Boolean); + // Durée d'inactivité au-delà de laquelle une session orpheline est fermée // (client parti sans DELETE explicite — évite une fuite mémoire lente). const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min -console.error(`[crowdlending-mcp-remote] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}`); +console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`); + +/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */ +const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title; + +/** Ajoute la source (URL API + libellé) en fin de description. */ +const withSource = (description) => + `${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`; /* ── Client API : une closure par session, liée à LA clé API de cette - session (jamais un module-level constant, contrairement à index.js). ── */ + session (jamais un module-level constant). ── */ function makeApiGet(apiKey) { return async function apiGet(path, params) { const url = new URL(API_BASE + path); @@ -104,7 +118,7 @@ setInterval(() => { const now = Date.now(); for (const [sessionId, s] of sessions.entries()) { if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) { - console.error(`[crowdlending-mcp-remote] session ${sessionId} inactive depuis plus de 30 min, fermeture.`); + console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] session ${sessionId} inactive depuis plus de 30 min, fermeture.`); s.transport.close(); sessions.delete(sessionId); } @@ -113,11 +127,9 @@ setInterval(() => { /** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */ async function createSession(apiKey) { - const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' }); - registerDataTools(server, { - apiGet: makeApiGet(apiKey), - withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`, - }); + const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' }); + registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource }); + if (ENABLE_FETCH_URL) registerFetchUrlTool(server); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), @@ -137,8 +149,7 @@ async function createSession(apiKey) { const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS }); // Limite basique anti-abus : une clé compromise ou un client buggé ne doit -// pas pouvoir marteler l'API backend sans frein. Comptabilisé par IP (la -// clé API n'est lue qu'après ce middleware). +// pas pouvoir marteler l'API backend sans frein. app.use(rateLimit({ windowMs: 60 * 1000, limit: 60, @@ -186,7 +197,7 @@ app.post('/mcp', async (req, res) => { const transport = await createSession(apiKey); await transport.handleRequest(req, res, req.body); } catch (e) { - console.error('[crowdlending-mcp-remote] erreur /mcp POST :', e); + console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] erreur /mcp POST :`, e); if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null }); } }); @@ -209,5 +220,5 @@ app.get('/mcp', handleExistingSession); app.delete('/mcp', handleExistingSession); app.listen(PORT, '0.0.0.0', () => { - console.error(`[crowdlending-mcp-remote] à l'écoute sur le port ${PORT}`); + console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] à l'écoute sur le port ${PORT}`); }); diff --git a/mcp-server/tools.js b/mcp-server/tools.js index a0bad3a..a4943b1 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -1,19 +1,26 @@ /** - * Outils MCP de lecture de données — partagés entre le serveur local (stdio, - * index.js) et le serveur distant (HTTP, http-server.js). + * Outils MCP — partagés par le serveur unique (server.js, HTTP), que ce soit + * en développement local (npm run dev, localhost:4100) ou déployé à distance + * (mcp.crowdlending.croguennec.net). * * Ce module ne contient AUCUNE logique de transport ni d'authentification : * il reçoit un `apiGet(path, params)` déjà prêt à l'emploi (déjà lié à une - * clé API et une base URL précises) et enregistre les 6 outils de lecture - * sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas - * diverger entre les deux serveurs — un seul endroit à maintenir. + * clé API et une base URL précises) et enregistre les outils sur le + * `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger + * selon l'environnement — un seul endroit à maintenir. * - * `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local - * (voir index.js) — l'exposer à des utilisateurs distants tiers via le - * serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage. + * `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée + * de façon CONDITIONNELLE par server.js (variable d'env + * `MCP_ENABLE_FETCH_URL`) : cet outil lit une URL arbitraire fournie par + * l'appelant, ce qui est sûr pour un usage perso (vous seul pouvez y + * accéder) mais présente un risque SSRF si exposé à des utilisateurs + * distants non maîtrisés — désactivé par défaut, à activer explicitement + * pour le développement local. */ import { z } from 'zod'; +import { JSDOM } from 'jsdom'; +import { Readability } from '@mozilla/readability'; const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, @@ -170,13 +177,148 @@ function toolResult(data) { }; } -/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. - * Exporté pour être réutilisé tel quel par le serveur HTTP distant (mêmes - * messages d'erreur que le serveur local, pour ne pas dérouter l'agent - * selon le transport utilisé). */ +/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */ export function toolError(e) { return { content: [{ type: 'text', text: `Erreur : ${e.message}` }], isError: true, }; } + +/* ── Outil fetch_url (Phase 3) — enregistrement conditionnel ───────────── + Récupère une page web (annonce de projet sur une plateforme, par ex.) et en + extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via + Readability — la même librairie que le mode lecture de Firefox. L'outil ne + fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/ + échéance côté serveur) : il fournit le texte propre, et c'est à l'agent + d'en extraire les informations pertinentes dans la conversation, puis de + les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du + serveur : aucune écriture, la création d'un investissement reste un geste + manuel dans l'app. ────────────────────────────────────────────────────── */ + +const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue +const FETCH_TIMEOUT_MS = 15000; +const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.2; +local-tool)'; + +/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire + * en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur + * (y compris son propre backend). Vérif sur le nom d'hôte littéral — ne + * résout pas le DNS, donc pas une protection anti-rebinding DNS complète, + * mais bloque les cas évidents (localhost, IP privées écrites en clair). */ +function isBlockedHost(hostname) { + const h = hostname.toLowerCase(); + if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true; + const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = ipv4.slice(1).map(Number); + if (a === 127 || a === 10 || a === 0) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 169 && b === 254) return true; + } + return false; +} + +/** Enregistre `crowdlending_fetch_url` sur `server`. À n'appeler que si + * `MCP_ENABLE_FETCH_URL` est activé côté server.js — voir note en tête de + * fichier. */ +export function registerFetchUrlTool(server) { + server.registerTool( + 'crowdlending_fetch_url', + { + title: 'Lire une page web', + description: + "Récupère une page web (ex. annonce d'un projet sur une plateforme de " + + "crowdlending) et en extrait le contenu lisible (titre + texte principal, " + + "débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " + + "dans l'app — l'agent doit extraire lui-même les informations utiles du " + + "texte retourné (taux, montant, durée, émetteur...) et les proposer à " + + "l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " + + "(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " + + `${CHARACTER_LIMIT} caractères sur les pages très longues.`, + inputSchema: { + url: z.string().url().describe("URL de la page à lire (http/https uniquement)"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ url }) => { + try { + let parsed; + try { parsed = new URL(url); } + catch { throw new Error(`URL invalide : ${url}`); } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`); + } + if (isBlockedHost(parsed.hostname)) { + throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`); + } + + let res; + try { + res = await fetch(parsed, { + headers: { + 'User-Agent': FETCH_USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml', + 'Accept-Language': 'fr-FR,fr;q=0.9', + }, + redirect: 'follow', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch (e) { + // Node/undici masque souvent la vraie cause derrière un message générique + // "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est + // dans e.cause, à remonter explicitement pour un diagnostic utile. + const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : ''; + throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`); + } + + if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim()); + + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('html')) { + throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`); + } + + const html = await res.text(); + const dom = new JSDOM(html, { url: parsed.toString() }); + const article = new Readability(dom.window.document).parse(); + + const title = article?.title || dom.window.document.title || null; + let text = (article?.textContent || dom.window.document.body?.textContent || '') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + const fullLength = text.length; + let truncated = false; + if (text.length > CHARACTER_LIMIT) { + text = text.slice(0, CHARACTER_LIMIT); + truncated = true; + } + + if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page."); + + const output = { + url: parsed.toString(), + title, + site_name: article?.siteName || null, + excerpt: article?.excerpt || null, + text, + length: fullLength, + truncated, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output, + }; + } catch (e) { return toolError(e); } + }, + ); +}