Update MCP server

This commit is contained in:
2026-07-15 22:59:44 +02:00
parent 5a0a1c03ac
commit 56dd1f89bd
9 changed files with 341 additions and 491 deletions
+11 -6
View File
@@ -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
-1
View File
@@ -2,4 +2,3 @@ node_modules
npm-debug.log
*.log
README.md
index.js
+2 -2
View File
@@ -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"]
+111 -167
View File
@@ -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_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json`
(le `<id>` 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).
-245
View File
@@ -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);
});
-3
View File
@@ -15,9 +15,6 @@
"jsdom": "^29.1.1",
"zod": "^3.25.0"
},
"bin": {
"crowdlending-mcp-server": "index.js"
},
"engines": {
"node": ">=18"
}
+5 -8
View File
@@ -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"
@@ -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}`);
});
+154 -12
View File
@@ -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); }
},
);
}