Compare commits

..

2 Commits

Author SHA1 Message Date
ocroguennec 6e58731a20 Fix 2026-07-15 23:08:36 +02:00
ocroguennec 56dd1f89bd Update MCP server 2026-07-15 22:59:44 +02:00
10 changed files with 419 additions and 524 deletions
+12 -6
View File
@@ -49,12 +49,13 @@ services:
- internal - internal
- backend # réseau Traefik - backend # réseau Traefik
# Serveur MCP distant (Phase 5) — accessible publiquement sur un # Serveur MCP (Phase 5) — même image/code que le serveur utilisé en
# sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au # développement local (mcp-server/server.js, npm run dev), déployé ici
# reste de l'app, ce service est volontairement ouvert à des utilisateurs # accessible publiquement sur un sous-domaine dédié, SANS le middleware
# distants qui ne peuvent pas déployer le serveur MCP local. La clé API # ipwhitelist-all : contrairement au reste de l'app, ce service est
# (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE # volontairement ouvert à des utilisateurs distants qui ne peuvent pas
# barrière d'accès — voir mcp-server/http-server.js. # 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: crowdlending-mcp:
build: build:
context: ./mcp-server context: ./mcp-server
@@ -66,6 +67,11 @@ services:
PORT: 4100 PORT: 4100
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1 CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost
MCP_LABEL: prod
# 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: depends_on:
crowdlending-backend: crowdlending-backend:
condition: service_healthy condition: service_healthy
+61 -45
View File
@@ -1091,27 +1091,30 @@ function ApiKeysSection() {
} }
/* ── Serveur MCP ────────────────────────────────────────────── /* ── Serveur MCP ──────────────────────────────────────────────
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP local Guide pas-à-pas pour connecter Claude Desktop au serveur MCP
(mcp-server/ à la racine du projet). Le JSON de config est généré (mcp-server/ à la racine du projet, un seul modèle url+headers en HTTP,
côté client à partir des champs ci-dessous ; la clé API saisie ici que ce soit en développement local ou déployé à distance en prod — voir
reste uniquement en mémoire du navigateur, elle n'est jamais envoyée mcp-server/README.md). Le JSON de config est généré côté client à partir
au backend — seulement utilisée pour composer l'aperçu à copier. ── */ des champs ci-dessous ; la clé API saisie ici reste uniquement en mémoire
du navigateur, elle n'est jamais envoyée au backend — seulement utilisée
pour composer l'aperçu à copier. ── */
/** Devine une URL d'API raisonnable selon l'environnement courant : /** Devine une URL de serveur MCP raisonnable selon l'environnement courant :
* en dev (Vite sur :5173), le process Node du serveur MCP ne passe pas * en dev (Vite sur :5173 ou localhost), le serveur MCP tourne en local sur
* par le proxy Vite, il faut donc viser directement le port du backend (4000). * son port par défaut (npm run dev, :4100). En prod, il s'agit du
* En prod (nginx sert front + /api sur la même origine), l'origine courante convient. */ * sous-domaine dédié mcp.<domaine de l'app>, déjà déployé en continu. */
function guessMcpApiUrl() { function guessMcpUrl() {
if (typeof window === 'undefined') return 'http://localhost:4000/api/v1'; if (typeof window === 'undefined') return 'http://localhost:4100/mcp';
const { hostname, port, origin } = window.location; const { hostname, port } = window.location;
if (port === '5173') return `http://${hostname}:4000/api/v1`; if (port === '5173' || hostname === 'localhost' || hostname === '127.0.0.1') {
return `${origin}/api/v1`; return 'http://localhost:4100/mcp';
}
return `https://mcp.${hostname}/mcp`;
} }
/** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de /** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de
* l'URL de l'API : localhost/IP locale ou nom d'hôte contenant "dev" → * l'URL du serveur MCP : localhost/IP locale → dev, tout le reste → prod.
* dev, tout le reste → prod. Best-effort — reste modifiable manuellement * Best-effort — reste modifiable manuellement pour les cas particuliers. */
* pour les cas particuliers (domaine de test qui ne contient pas "dev"...). */
function detectLabelFromUrl(url) { function detectLabelFromUrl(url) {
if (!url) return 'prod'; if (!url) return 'prod';
let hostname; let hostname;
@@ -1146,27 +1149,28 @@ function CopyBlock({ text }) {
} }
function McpServerSection({ goToApiKeys }) { function McpServerSection({ goToApiKeys }) {
const [mcpPath, setMcpPath] = useState('C:\\dev\\crowdlending-app\\mcp-server\\index.js'); const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
const [apiUrl, setApiUrl] = useState(guessMcpApiUrl());
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis apiUrl, sinon override manuel const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
const detectedLabel = detectLabelFromUrl(apiUrl); const detectedLabel = detectLabelFromUrl(mcpUrl);
const label = manualLabel ?? detectedLabel; const label = manualLabel ?? detectedLabel;
const isLocal = label === 'dev';
const serverKey = `crowdlending-${label}`; const serverKey = `crowdlending-${label}`;
const env = { // claude_desktop_config.json n'a pas de champ url/headers natif : on passe
CROWDLENDING_API_KEY: apiKey || '<VOTRE_CLE_API>', // par mcp-remote (https://github.com/geelen/mcp-remote), un pont stdio↔HTTP
CROWDLENDING_API_URL: apiUrl, // que Claude Desktop lance comme n'importe quel serveur "command". La clé
CROWDLENDING_LABEL: label, // API passe en variable d'environnement plutôt que directement dans args
}; // (bug connu de Claude Desktop Windows qui tronque les valeurs à espaces).
const envVarName = `${label.toUpperCase()}_API_KEY`;
const configJson = JSON.stringify({ const configJson = JSON.stringify({
mcpServers: { mcpServers: {
[serverKey]: { [serverKey]: {
command: 'node', command: 'npx',
args: [mcpPath], args: ['-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`],
env, env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' },
}, },
}, },
}, null, 2); }, null, 2);
@@ -1176,8 +1180,8 @@ function McpServerSection({ goToApiKeys }) {
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3> <h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}> <p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule. Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule.
Le serveur (dossier <code>mcp-server/</code> du projet) doit être installé sur cette machine Un seul modèle de connexion, en développement local comme en production : une URL de serveur MCP
(<code>npm install</code>) voir <code>mcp-server/README.md</code> pour le détail. et votre clé API personnelle voir <code>mcp-server/README.md</code> pour le détail.
</p> </p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
@@ -1194,12 +1198,8 @@ function McpServerSection({ goToApiKeys }) {
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4> <h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
<div> <div>
<label>Chemin vers mcp-server/index.js</label> <label>URL du serveur MCP</label>
<input value={mcpPath} onChange={e => setMcpPath(e.target.value)} /> <input value={mcpUrl} onChange={e => setMcpUrl(e.target.value)} />
</div>
<div>
<label>URL de l'API</label>
<input value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
</div> </div>
<div> <div>
<label>Clé API</label> <label>Clé API</label>
@@ -1218,7 +1218,7 @@ function McpServerSection({ goToApiKeys }) {
</span> </span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{manualLabel === null {manualLabel === null
? <>déduit de l'URL de l'API ci-dessus (localhost / « dev » → dev, sinon prod)</> ? <>déduit de l'URL ci-dessus (localhost → dev, sinon prod)</>
: <>forcé manuellement</>} : <>forcé manuellement</>}
</span> </span>
</div> </div>
@@ -1236,12 +1236,26 @@ function McpServerSection({ goToApiKeys }) {
)} )}
</div> </div>
</div> </div>
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{isLocal ? (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
En développement, ce serveur doit tourner sur votre machine pour que l'URL ci-dessus réponde :
<code style={{ display: 'block', margin: '6px 0', padding: '6px 8px', borderRadius: 6, background: 'var(--surface-2, #f9fafb)' }}>
cd mcp-server &amp;&amp; npm install &amp;&amp; npm run dev
</code>
comme pour le backend et le frontend. Laissez ce terminal ouvert tant que vous utilisez Claude Desktop.
</p>
) : (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
En production, le serveur tourne déjà en continu (service <code>crowdlending-mcp</code>) aucune
action nécessaire au-delà de renseigner l'URL et votre clé API.
</p>
)}
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et
prod accessibles simultanément, répétez ces étapes une deuxième fois avec une URL d'API pointant prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé
vers l'autre environnement (et une clé API distincte) — l'étiquette et la clé de config API distincte) la clé de config (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
(<code>{serverKey}</code> ci-dessous) s'ajustent automatiquement. Elle apparaît dans le titre et Claude Desktop ne confondra jamais les deux portefeuilles.
la description de chaque outil, pour que l'agent ne confonde jamais les deux portefeuilles.
</p> </p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
@@ -1268,8 +1282,10 @@ function McpServerSection({ goToApiKeys }) {
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans la liste des outils MCP de Claude Desktop, les 7 outils <code>crowdlending_*</code> doivent Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
apparaître. Testez avec une question du type « Quel est mon encours de crowdlending actuellement ? ». apparaître (6 en production, 7 en développement local si <code>crowdlending_fetch_url</code> est
activé — voir <code>mcp-server/README.md</code>). Testez avec une question du type « Quel est mon
encours de crowdlending actuellement ? ».
</p> </p>
</div> </div>
); );
-1
View File
@@ -2,4 +2,3 @@ node_modules
npm-debug.log npm-debug.log
*.log *.log
README.md README.md
index.js
+2 -2
View File
@@ -5,11 +5,11 @@ ENV NODE_ENV=production
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY tools.js http-server.js ./ COPY tools.js server.js ./
EXPOSE 4100 EXPOSE 4100
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ 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 -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"]
+127 -155
View File
@@ -1,28 +1,20 @@
# crowdlending-mcp-server # crowdlending-mcp-server
Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
les données d'un investisseur (investissements, remboursements, crowdlending. Il expose en lecture seule les données d'un investisseur
dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code, (investissements, remboursements, dépôts/retraits, dashboard) à un client
ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1` MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
du backend. s'appuyant sur l'API publique `/api/v1` du backend.
Il ne fait aucune écriture : toutes les modifications restent à faire dans Il ne fait aucune écriture : toutes les modifications restent à faire dans
l'app web. l'app web.
Deux modes, deux fichiers : **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
- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude production (service Docker `crowdlending-mcp`, derrière Traefik sur
Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en `mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les
local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web). deux — seule la configuration change (quelle API cibler, quels outils
- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par activer).
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.
## Prérequis ## Prérequis
@@ -31,184 +23,149 @@ modes.
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre - Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
instance en production) instance en production)
## Installation ## Développement local
Comme pour `backend/` et `frontend/` :
``` ```
cd mcp-server cd mcp-server
npm install 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 : Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
| 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.
```json ```json
{ {
"mcpServers": { "mcpServers": {
"crowdlending-dev": { "crowdlending-dev": {
"command": "node", "command": "npx",
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], "args": [
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": { "env": {
"CROWDLENDING_API_KEY": "clk_live_...", "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 **Important :** `claude_desktop_config.json` n'accepte que des entrées
sur chaque backend) : ça permet de révoquer l'une sans affecter l'autre, et `command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
c'est cohérent avec le principe d'une clé par usage. fichier (contrairement à d'autres clients MCP). Pour un serveur HTTP comme
celui-ci, on passe donc par
[`mcp-remote`](https://github.com/geelen/mcp-remote), un petit pont
stdio↔HTTP officiel : Claude Desktop lance `npx mcp-remote` comme d'habitude,
qui se charge de parler HTTP à notre serveur en coulisses, en-tête
`X-API-Key` inclus. Aucune installation manuelle requise, `npx` le télécharge
à la volée.
Avec `CROWDLENDING_LABEL` renseigné, chaque outil affiche son environnement Notez l'absence d'espace autour du `:` dans `--header` : Claude Desktop
dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description (Windows) a un bug connu qui tronque les arguments contenant un espace — on
se termine par la source exacte interrogée — de quoi lever toute ambiguïté passe donc la valeur réelle (avec l'espace éventuel) via une variable
si vous demandez « mon encours en prod » vs « mon encours en dev ». d'environnement dans `env` plutôt que directement dans `args`.
## Serveur distant — pour les utilisateurs sans installation locale Redémarrez Claude Desktop. Les outils `crowdlending_*` doivent apparaître
(voir la liste plus bas — `crowdlending_fetch_url` en plus si activé, voir
Configuration).
Si vous n'êtes pas sur la machine qui héberge ce dépôt (pas de Node.js, pas ## Configuration
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 Variables d'environnement, toutes optionnelles sauf pour un déploiement
clé (elle ne sera affichée qu'une seule fois, copiez-la). distant réel (en local, les valeurs par défaut conviennent) :
**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur → | Variable | Défaut | Description |
Serveurs MCP locaux → Modifier la config, puis ajoutez : |---|---|---|
| `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) |
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
(transmis via `mcp-remote --header`, voir 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 ```json
{ {
"mcpServers": { "mcpServers": {
"crowdlending-distant": { "crowdlending-dev": {
"url": "https://mcp.crowdlending.croguennec.net/mcp", "command": "npx",
"headers": { "args": ["-y", "mcp-remote", "http://localhost:4100/mcp", "--header", "X-API-Key:${DEV_API_KEY}"],
"X-API-Key": "clk_live_..." "env": { "DEV_API_KEY": "clk_live_..." }
} },
"crowdlending-prod": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.crowdlending.croguennec.net/mcp", "--header", "X-API-Key:${PROD_API_KEY}"],
"env": { "PROD_API_KEY": "clk_live_..." }
} }
} }
} }
``` ```
**3. Redémarrez Claude Desktop.** Les 6 outils `crowdlending_*` doivent Utilisez deux clés API différentes (une par instance) : ça permet de
apparaître (sans `crowdlending_fetch_url`, réservé au serveur local). 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.
### Différences avec le serveur local ## Déploiement en production (Docker)
- **Pas de `crowdlending_fetch_url`** : lire une page web arbitraire pour un Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit
utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice et lance ce même `server.js`, exposé via Traefik sur
réel pour ce cas d'usage. Cet outil reste réservé au serveur local. `mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement
- **Authentification par requête, pas par process** : le serveur local a une au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` :
clé API fixée une fois pour toutes via une variable d'environnement — un il est volontairement accessible depuis internet, pour des utilisateurs
process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en distants qui ne peuvent pas faire tourner le serveur en local — la clé API
parallèle : chaque session est créée à partir de la clé API envoyée dans est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et
l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette révoquez-la immédiatement en cas de doute (Mon compte → Clés API).
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.
### Déploiement (administrateur de l'instance) Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
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
`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement. `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` `MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml)
(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit : cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour
correspondre exactement au(x) nom(s) de domaine exposé(s)). 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 ## Tester sans Claude Desktop
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
de lister et appeler les outils depuis une interface web, sans configurer de de lister et appeler les outils depuis une interface web :
client :
``` ```
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 ## Outils exposés
Tous en lecture seule (`readOnlyHint: true`) : Tous en lecture seule (`readOnlyHint: true`) :
@@ -221,10 +178,14 @@ Tous en lecture seule (`readOnlyHint: true`) :
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements | | `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période | | `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash | | `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`) ## 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 + Cet outil récupère une page web et en extrait le contenu lisible (titre +
texte principal) via [Readability](https://github.com/mozilla/readability), texte principal) via [Readability](https://github.com/mozilla/readability),
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
@@ -249,10 +210,21 @@ Garde-fous :
## Dépannage ## Dépannage
- **`ERREUR : CROWDLENDING_API_KEY est requise`** — la variable d'env n'est - **`En-tête X-API-Key manquant`** — vérifiez l'argument `--header` et la
pas transmise. Vérifiez la section `env` de votre config MCP. variable d'environnement correspondante dans votre config Claude Desktop.
- **« Certains serveurs MCP n'ont pas pu être chargés » / entrée ignorée** —
votre config utilise un bloc `url`/`headers` directement, non supporté par
`claude_desktop_config.json` (voir Développement local plus haut) : il faut
passer par `command: "npx"` + `mcp-remote`.
- **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte → - **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte →
Clés API et mettez à jour la config. Clés API et mettez à jour la config.
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou - **`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 `CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le
suffixe `/api/v1`). 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", "jsdom": "^29.1.1",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"bin": {
"crowdlending-mcp-server": "index.js"
},
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
+5 -8
View File
@@ -1,16 +1,13 @@
{ {
"name": "crowdlending-mcp-server", "name": "crowdlending-mcp-server",
"version": "0.2.0", "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", "type": "module",
"main": "index.js", "main": "server.js",
"bin": {
"crowdlending-mcp-server": "./index.js"
},
"scripts": { "scripts": {
"start": "node index.js", "start": "node server.js",
"start:http": "node http-server.js", "dev": "node --watch server.js",
"inspect": "npx @modelcontextprotocol/inspector node index.js" "inspect": "npx @modelcontextprotocol/inspector"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1,33 +1,26 @@
#!/usr/bin/env node #!/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 * Point d'entrée unique, que ce soit en développement local (`npm run dev`,
* lecture (voir tools.js), mais accessible via une URL publique plutôt que * comme le backend et le frontend) ou déployé à distance en production
* comme process enfant sur la machine de l'utilisateur. Pensé pour les * (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
* utilisateurs distants qui ne peuvent/veulent pas installer Node.js et * docker-compose). Un seul modèle de transport, un seul fichier à
* cloner ce dépôt en local ils n'ont qu'à ajouter une URL + leur clé API * maintenir : les outils eux-mêmes vivent dans tools.js.
* personnelle dans la config de leur client MCP (Claude Desktop, etc.).
* *
* DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est * AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
* lancé une fois par utilisateur, avec UNE clé API fixée par variable * l'ancien serveur stdio. Un même process peut servir plusieurs
* d'environnement pour toute la durée du process. Ici, un seul process sert * utilisateurs/sessions en parallèle (typiquement un seul en dev local,
* potentiellement PLUSIEURS utilisateurs distants en parallèle il n'y a * potentiellement plusieurs en déploiement distant) chaque session MCP est
* donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée * initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de
* à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête * la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure
* HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des * des outils de CETTE session uniquement (voir createSession). Deux sessions
* outils de CETTE session uniquement (voir createSession ci-dessous). Deux * ne partagent jamais d'état ni de données.
* utilisateurs distants ne partagent jamais d'état ni de données.
* *
* `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le * Connexion depuis Claude Desktop (dev comme prod) :
* détail) : réservé au serveur local, pour limiter le risque SSRF envers * { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } }
* des utilisateurs tiers non maîtrisés. * (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la
* * prod). Voir README.md pour le détail.
* 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.
*/ */
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -36,28 +29,49 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import rateLimit from 'express-rate-limit'; 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 PORT = Number(process.env.PORT || 4100);
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, ''); const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost: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). // Étiquette facultative pour distinguer plusieurs instances connectées en
// `localhost` est inclus par défaut pour que le HEALTHCHECK Docker (qui // même temps à Claude Desktop (ex. dev local + prod distante). Affiche
// interroge http://localhost:4100/health depuis l'intérieur du container) // "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
// ne soit pas lui-même bloqué par ce middleware — sans risque côté externe, // de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
// puisque Traefik ne route déjà vers ce service que les requêtes portant le const LABEL = (process.env.MCP_LABEL || '').trim();
// Host public configuré sur son router (une requête Host: localhost envoyée
// depuis l'extérieur n'atteint jamais ce container). // 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') const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
.split(',').map((h) => h.trim()).filter(Boolean); .split(',').map((h) => h.trim()).filter(Boolean);
// Durée d'inactivité au-delà de laquelle une session orpheline est fermée // 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). // (client parti sans DELETE explicite — évite une fuite mémoire lente).
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min 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 /* 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) { function makeApiGet(apiKey) {
return async function apiGet(path, params) { return async function apiGet(path, params) {
const url = new URL(API_BASE + path); const url = new URL(API_BASE + path);
@@ -104,7 +118,7 @@ setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [sessionId, s] of sessions.entries()) { for (const [sessionId, s] of sessions.entries()) {
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) { 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(); s.transport.close();
sessions.delete(sessionId); sessions.delete(sessionId);
} }
@@ -113,11 +127,9 @@ setInterval(() => {
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */ /** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
async function createSession(apiKey) { async function createSession(apiKey) {
const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' }); const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
registerDataTools(server, { registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
apiGet: makeApiGet(apiKey), if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`,
});
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
@@ -137,8 +149,7 @@ async function createSession(apiKey) {
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
// Limite basique anti-abus : une clé compromise ou un client buggé ne doit // 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 // pas pouvoir marteler l'API backend sans frein.
// clé API n'est lue qu'après ce middleware).
app.use(rateLimit({ app.use(rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
limit: 60, limit: 60,
@@ -186,7 +197,7 @@ app.post('/mcp', async (req, res) => {
const transport = await createSession(apiKey); const transport = await createSession(apiKey);
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
} catch (e) { } 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 }); 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.delete('/mcp', handleExistingSession);
app.listen(PORT, '0.0.0.0', () => { 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, * Outils MCP — partagés par le serveur unique (server.js, HTTP), que ce soit
* index.js) et le serveur distant (HTTP, http-server.js). * 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 : * 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 * 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 * clé API et une base URL précises) et enregistre les outils sur le
* sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas * `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
* diverger entre les deux serveurs — un seul endroit à maintenir. * selon l'environnement — un seul endroit à maintenir.
* *
* `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local * `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
* (voir index.js) — l'exposer à des utilisateurs distants tiers via le * de façon CONDITIONNELLE par server.js (variable d'env
* serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage. * `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 { z } from 'zod';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
const READ_ONLY_ANNOTATIONS = { const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true, readOnlyHint: true,
@@ -170,13 +177,148 @@ function toolResult(data) {
}; };
} }
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. /** 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é). */
export function toolError(e) { export function toolError(e) {
return { return {
content: [{ type: 'text', text: `Erreur : ${e.message}` }], content: [{ type: 'text', text: `Erreur : ${e.message}` }],
isError: true, 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); }
},
);
}