API V1 Public + preparation serveur MCP

This commit is contained in:
2026-07-15 19:33:13 +02:00
parent c843464ccd
commit 4d8fb9bab8
8 changed files with 2541 additions and 9 deletions
+40 -1
View File
@@ -1,5 +1,5 @@
# MEMORY.md — Crowdlending Tracker # MEMORY.md — Crowdlending Tracker
*Dernière mise à jour: 2026-07-14 (session 14)* *Dernière mise à jour: 2026-07-14 (session 15)*
--- ---
@@ -680,3 +680,42 @@ Le mount bash reste périmé (cf. sessions précédentes). Pour vérifier un **n
- Scopes : toutes les données / dépôts-retraits / investissements (+ remboursements liés en cascade) / remboursements uniquement. La fiche plateforme n'est jamais supprimée. - Scopes : toutes les données / dépôts-retraits / investissements (+ remboursements liés en cascade) / remboursements uniquement. La fiche plateforme n'est jamais supprimée.
- **Confirmation par PIN à 6 chiffres** (remplace le "retapez le nom de la plateforme") : PIN aléatoire généré à l'ouverture de la modale (`Math.floor(100000 + Math.random()*900000)`), affiché en gros (monospace 30px, espacé, rouge) ; le payload envoyé à l'API contient toujours `confirmNom: purgePlat.nom` en interne (le PIN est une couche de confirmation UI uniquement, la vérification serveur par nom exact est inchangée). - **Confirmation par PIN à 6 chiffres** (remplace le "retapez le nom de la plateforme") : PIN aléatoire généré à l'ouverture de la modale (`Math.floor(100000 + Math.random()*900000)`), affiché en gros (monospace 30px, espacé, rouge) ; le payload envoyé à l'API contient toujours `confirmNom: purgePlat.nom` en interne (le PIN est une couche de confirmation UI uniquement, la vérification serveur par nom exact est inchangée).
- **Sélecteur de plateforme corrigé pour le multi-détenteur** : deux plateformes de familles différentes peuvent porter le même nom (ex. deux comptes "Enky"). Fix en reprenant le pattern déjà établi ailleurs (`const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1`, suffixe `— {investisseur_nom}` dans les `<option>` et rappel dans le texte de la modale, uniquement si multi-détenteur) — cf. session 9 "Colonne Détenteur — masquée si mono-détenteur", même pattern à répliquer sur tout futur select de plateformes. - **Sélecteur de plateforme corrigé pour le multi-détenteur** : deux plateformes de familles différentes peuvent porter le même nom (ex. deux comptes "Enky"). Fix en reprenant le pattern déjà établi ailleurs (`const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1`, suffixe `— {investisseur_nom}` dans les `<option>` et rappel dans le texte de la modale, uniquement si multi-détenteur) — cf. session 9 "Colonne Détenteur — masquée si mono-détenteur", même pattern à répliquer sur tout futur select de plateformes.
---
## Session 15 — Roadmap API publique + serveur MCP, phases 0 à 2 (2026-07-14)
Objectif produit (discuté et validé avant implémentation, cf. règle "poser des questions avant tâche complexe") : ouvrir une API en lecture seule + un serveur MCP local pour piloter l'app via un agent IA (Claude Desktop). Roadmap en 5 phases actée : 0) clés API, 1) API publique v1 + doc, 2) serveur MCP local (stdio), 3) outil `fetch_url` (non fait), 4) déploiement Docker prod, 5) MCP distant (non fait, plus tard). **Phases 0, 1, 2 et le déploiement en prod (phase 4) sont faites et validées par l'utilisateur en production.**
### Phase 0 — Clés API
- Nouvelle table `api_keys` (migration `db/index.js`) : `user_id`, `investisseur_id` (une clé = un seul investisseur, jamais tous), `nom`, `key_prefix` (12 premiers car. affichés dans l'UI), `key_hash` (SHA-256, jamais le clair stocké), `scopes` (`'read'` seul utilisé), `created_at`, `last_used_at`, `revoked_at`.
- Format de clé : `clk_live_` + 24 octets hex aléatoires. Générée par `backend/src/routes/apiKeys.js` (monté `/api/api-keys`, protégé par le JWT interne classique — c'est l'utilisateur qui gère ses clés via l'app, pas la clé elle-même qui authentifie ces routes).
- Deux suppressions distinctes, bien différenciées côté UI (`MonCompte.jsx`, section "Clés API") :
- **Révocation** (`DELETE /api-keys/:id`) : soft-delete, `revoked_at` set, la ligne reste visible pour historique/`last_used_at`.
- **Suppression définitive** (`DELETE /api-keys/:id/purge`) : hard-delete. Icône poubelle par ligne — sur une clé **active**, ouvre une modale d'avertissement (irréversible, coupe l'accès immédiatement, suggère plutôt "Révoquer") ; sur une clé **déjà révoquée**, suppression directe sans confirmation (elle ne sert déjà plus à rien).
- **Piège sécurité observé en session** : l'utilisateur a collé une clé API en clair dans le chat pour "tester". Réflexe correct appliqué : recommander révocation immédiate + regénération, rappeler qu'une clé ne doit transiter que entre l'app et le fichier de config local, jamais par un canal de conversation.
### Phase 1 — API publique `/api/v1` (lecture seule) + Swagger
- Middleware `backend/src/middleware/apiKey.js` (`requireApiKey`) : lit `X-API-Key`, hash SHA-256, vérifie non révoquée, met à jour `last_used_at`, injecte `req.investisseurId`/`req.apiKeyId`/`req.apiScopes`. Distinct de `requireAuth` (JWT).
- Routes dans `backend/src/routes/v1/` (agrégées par `v1/index.js`) : `GET /investisseur`, `GET /investissements` (+ filtre `?statut=`), `GET /investissements/:id` (avec ses remboursements), `GET /remboursements` (+ `?date_debut=&date_fin=`), `GET /depots-retraits`, `GET /dashboard` (KPIs simplifiés, requêtes SQL propres à v1, pas de réutilisation de la logique complexe de `routes/dashboard.js`). Toutes scopées strictement à `req.investisseurId` — pas de notion `scope=all` ici (une clé = un investisseur).
- **Piège routing critique (corrigé)** : `server.js` a une route générique `app.use('/api', requireAuth, associationsInvRouter)` qui capte tout préfixe `/api/*`. Le montage de `/api/v1` et `/api/docs` doit impérativement se faire **avant** cette ligne (juste après `/api/auth`), sinon toute requête vers `/api/v1/*` est interceptée par le JWT interne et renvoie 401 "Missing or invalid Authorization header" au lieu d'atteindre `requireApiKey`.
- Doc Swagger : `backend/src/swagger.js` (`swagger-jsdoc` + `swagger-ui-express`), annotations `@openapi` dans chaque route `v1/*.js`, servie sur `/api/docs` (public) + `/api/openapi.json`. Dépendances ajoutées à `backend/package.json`.
- **Piège Windows** : `swagger-jsdoc` résout son option `apis` (glob) via une lib qui n'interprète pas les antislashs — `path.join(__dirname, 'routes/v1/*.js')` sous Windows produit des `\`, donc 0 route détectée ("No operations defined in spec!"). Fix : forcer des `/` (`p.split(path.sep).join('/')`) avant de passer le pattern à `swagger-jsdoc`.
- **Piège annotations** : le `server.url` OpenAPI est déjà `/api/v1` — les chemins `@openapi` doivent être relatifs (`/dashboard`, pas `/v1/dashboard`), sinon Swagger UI construit des URLs doublées (`/api/v1/v1/dashboard`) qui retombent sur la route catch-all JWT (401 trompeur, à ne pas confondre avec un vrai problème de clé).
- Testé en prod par l'utilisateur : création clé → doc Swagger (Authorize + Try it out) → 200 avec vraies données → révocation → 401 `"Invalid or revoked API key"`. Confirmé aussi compatible Power Query Excel (`Web.Contents` + header `X-API-Key`, pas de souci CORS car requête serveur-à-serveur).
### Phase 2 — Serveur MCP local (`mcp-server/`, nouveau dossier à la racine)
- Package Node **séparé** du monorepo (son propre `package.json`), pur JS sans dépendance native (`@modelcontextprotocol/sdk` + `zod`) — contrairement au backend (`better-sqlite3`), donc **installable et testable tel quel dans le sandbox Linux**, aucun problème cross-plateforme.
- `index.js` : `McpServer` + `StdioServerTransport`, 6 outils lecture seule préfixés `crowdlending_` (`get_investisseur`, `get_dashboard`, `list_investissements`, `get_investissement`, `list_remboursements`, `list_depots_retraits`), chacun appelle l'API v1 via `fetch` natif (Node ≥ 18) avec le header `X-API-Key`. Auth par `CROWDLENDING_API_KEY` (obligatoire, `process.exit(1)` sinon) + `CROWDLENDING_API_URL` (défaut `http://localhost:4000/api/v1`).
- **Règle stdio impérative** : ne jamais `console.log` dans ce process (stdout = canal protocole JSON-RPC) — uniquement `console.error` pour les diagnostics. Vérifié en session (stdout capturé vide, stderr contient les logs).
- **Distinction dev/prod pour connexions simultanées** : `CROWDLENDING_LABEL` (env var facultative) préfixe le nom du serveur MCP et ajoute `[DEV]`/`[PROD]` au titre + la source (URL API) en fin de description de chaque outil. Pas de collision technique possible entre deux instances connectées en même temps à Claude Desktop même si les noms d'outils sont identiques : le client namespace déjà par clé du bloc `mcpServers` (comme observé dans ce contexte agent : `mcp__<serveur>__<outil>`). Le `LABEL` sert uniquement à la lisibilité humaine/agent.
- UI `MonCompte.jsx`, section "Serveur MCP" (nav `id: 'mcp'`) : guide pas-à-pas (créer une clé dédiée → renseigner chemin/URL/clé → JSON généré à copier → redémarrer Claude Desktop → vérifier). Le JSON est composé **côté client uniquement** — la clé saisie dans le champ n'est jamais envoyée au backend, juste utilisée pour l'aperçu affiché.
- **Détection automatique dev/prod par URL** (`detectLabelFromUrl`) : hostname `localhost`/`127.0.0.1`/`.local` ou contenant "dev" → `DEV`, sinon `PROD` ; badge coloré affiché, case à cocher "Forcer manuellement" pour les cas ambigus (ex. domaine de test sans "dev" dans le nom). `guessMcpApiUrl()` distingue déjà dev (`:5173` → viser directement le backend `:4000`, le process Node MCP ne passe pas par le proxy Vite) vs prod (même origine que le frontend, nginx proxy `/api`).
- `README.md` dédié dans `mcp-server/` : install, config Claude Desktop, double config dev+prod, test via MCP Inspector, tableau des 6 outils, dépannage.
### Piège outillage — cache bash figé sur un fichier précis
Le mount bash (lecture de `C:\dev\crowdlending-app` depuis le sandbox Linux) a servi une version **figée à la toute première écriture** de `mcp-server/index.js` (même taille en octets, même `mtime`) malgré plusieurs `Edit` puis un `Write` complet ultérieurs — contrairement au comportement habituel de simple lag résolu par un `sleep`. Confirmé via `stat` (mtime figé). Contournement qui a fonctionné : écrire un fichier de contenu équivalent **directement via bash** (heredoc, sans passer par le mount Windows) dans `/tmp`, et valider la syntaxe/le comportement dessus — le `Read` tool (accès direct au filesystem Windows) reste la seule source fiable pour le contenu réel du fichier concerné.
### Reste à faire (roadmap)
- Phase 3 : outil MCP `fetch_url` (extraction structurée d'une page plateforme pour pré-remplir un investissement, sans écriture automatique).
- Phase 5 : variante MCP distante (HTTP/SSE, OAuth) dans le `docker-compose.yml` de prod — plus tard, une fois l'usage local stabilisé.
+40 -7
View File
@@ -8,23 +8,56 @@ const router = Router();
* /dashboard: * /dashboard:
* get: * get:
* summary: Synthèse du portefeuille (KPIs) * summary: Synthèse du portefeuille (KPIs)
* description: >
* Les champs `investissements.*` sont des soldes actuels (photo à
* aujourd'hui), pas des cumuls par période — un investissement remboursé
* partiellement reste compté pour son capital restant dû tant qu'il
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
* par année via `?annee=`.
* tags: [Dashboard] * tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }] * security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: annee
* schema: { type: integer }
* description: >
* Filtre les intérêts/capital reçu sur une année (ex. 2026).
* Omis = cumul sur toute la durée du portefeuille.
* responses: * responses:
* 200: { description: Synthèse KPI } * 200: { description: Synthèse KPI }
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const invId = req.investisseurId; const invId = req.investisseurId;
const annee = req.query.annee ? Number(req.query.annee) : null;
// ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
// en_defaut). "capital_investi" et "capital_en_risque" sont des soldes
// nettés (montant souscrit + réinvestissements capital déjà remboursé sur
// CET investissement), pas une simple somme de montant_investi par statut —
// ça gère correctement les prêts amortissables partiellement remboursés.
const investissements = db.prepare(` const investissements = db.prepare(`
SELECT SELECT
COUNT(*) AS nb_investissements, COUNT(*) AS nb_investissements,
COALESCE(SUM(montant_investi), 0) AS total_investi, COALESCE(SUM(i.montant_investi), 0) AS total_investi,
COALESCE(SUM(CASE WHEN statut='en_cours' THEN montant_investi END), 0) AS encours, COALESCE(SUM(CASE WHEN i.statut IN ('en_cours','en_retard','procedure') THEN
COALESCE(SUM(CASE WHEN statut='rembourse' THEN montant_investi END), 0) AS rembourse i.montant_investi
FROM investissements WHERE investisseur_id = ? + COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS capital_investi,
COALESCE(SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS capital_en_risque,
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
FROM investissements i WHERE i.investisseur_id = ?
`).get(invId); `).get(invId);
const interetsConds = ['i.investisseur_id = ?'];
const interetsParams = [invId];
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
const interets = db.prepare(` const interets = db.prepare(`
SELECT SELECT
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
@@ -33,8 +66,8 @@ router.get('/', (req, res) => {
COALESCE(SUM(r.net_recu), 0) AS net_recu_total COALESCE(SUM(r.net_recu), 0) AS net_recu_total
FROM remboursements r FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id JOIN investissements i ON i.id = r.investissement_id
WHERE i.investisseur_id = ? WHERE ${interetsConds.join(' AND ')}
`).get(invId); `).get(...interetsParams);
const cash = db.prepare(` const cash = db.prepare(`
SELECT SELECT
@@ -43,7 +76,7 @@ router.get('/', (req, res) => {
FROM depots_retraits WHERE investisseur_id = ? FROM depots_retraits WHERE investisseur_id = ?
`).get(invId); `).get(invId);
res.json({ investissements, interets, cash }); res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
}); });
export default router; export default router;
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+190
View File
@@ -22,6 +22,9 @@ function IconKey() {
function IconTrash() { function IconTrash() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>; return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>;
} }
function IconServer() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01"/><path d="M6 17.5h.01"/></svg>;
}
/* ── Dropdown custom style Finary ────────────────────────────── */ /* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [ const LANGUES = [
@@ -1087,6 +1090,191 @@ function ApiKeysSection() {
); );
} }
/* ── Serveur MCP ──────────────────────────────────────────────
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP local
(mcp-server/ à la racine du projet). Le JSON de config est généré
côté client à partir 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 :
* en dev (Vite sur :5173), le process Node du serveur MCP ne passe pas
* par le proxy Vite, il faut donc viser directement le port du backend (4000).
* En prod (nginx sert front + /api sur la même origine), l'origine courante convient. */
function guessMcpApiUrl() {
if (typeof window === 'undefined') return 'http://localhost:4000/api/v1';
const { hostname, port, origin } = window.location;
if (port === '5173') return `http://${hostname}:4000/api/v1`;
return `${origin}/api/v1`;
}
/** 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" →
* dev, tout le reste → prod. Best-effort — reste modifiable manuellement
* pour les cas particuliers (domaine de test qui ne contient pas "dev"...). */
function detectLabelFromUrl(url) {
if (!url) return 'prod';
let hostname;
try { hostname = new URL(url).hostname.toLowerCase(); }
catch { hostname = url.toLowerCase(); }
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|| hostname.endsWith('.local');
return (isLocal || hostname.includes('dev')) ? 'dev' : 'prod';
}
function CopyBlock({ text }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard indisponible */ }
};
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 12,
}}>
<pre style={{ flex: 1, margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{text}</pre>
<button type="button" className="ghost" onClick={copy} style={{ flexShrink: 0 }}>
{copied ? 'Copié ✓' : 'Copier'}
</button>
</div>
);
}
function McpServerSection({ goToApiKeys }) {
const [mcpPath, setMcpPath] = useState('C:\\dev\\crowdlending-app\\mcp-server\\index.js');
const [apiUrl, setApiUrl] = useState(guessMcpApiUrl());
const [apiKey, setApiKey] = useState('');
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis apiUrl, sinon override manuel
const detectedLabel = detectLabelFromUrl(apiUrl);
const label = manualLabel ?? detectedLabel;
const serverKey = `crowdlending-${label}`;
const env = {
CROWDLENDING_API_KEY: apiKey || '<VOTRE_CLE_API>',
CROWDLENDING_API_URL: apiUrl,
CROWDLENDING_LABEL: label,
};
const configJson = JSON.stringify({
mcpServers: {
[serverKey]: {
command: 'node',
args: [mcpPath],
env,
},
},
}, null, 2);
return (
<div className="card" style={{ marginTop: 20 }}>
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
<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.
Le serveur (dossier <code>mcp-server/</code> du projet) doit être installé sur cette machine
(<code>npm install</code>) voir <code>mcp-server/README.md</code> pour le détail.
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Utilisez une clé distincte pour le serveur MCP (ex. nommée « MCP Desktop »), pour pouvoir la
révoquer indépendamment des autres usages.
</p>
{goToApiKeys && (
<button type="button" className="ghost" onClick={goToApiKeys} style={{ marginBottom: 20 }}>
Aller créer une clé
</button>
)}
<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>
<label>Chemin vers mcp-server/index.js</label>
<input value={mcpPath} onChange={e => setMcpPath(e.target.value)} />
</div>
<div>
<label>URL de l'API</label>
<input value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
</div>
<div>
<label>Clé API</label>
<input value={apiKey} onChange={e => setApiKey(e.target.value)}
placeholder="clk_live_… (collée ici juste pour générer l'aperçu ci-dessous, non envoyée)" />
</div>
<div>
<label>Environnement détecté</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<span style={{
fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 10,
background: label === 'dev' ? 'var(--warning-bg, #fffbeb)' : 'var(--success-bg, #f0fdf4)',
color: label === 'dev' ? 'var(--warning-text, #92400e)' : 'var(--success, #16a34a)',
}}>
{label.toUpperCase()}
</span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{manualLabel === null
? <>déduit de l'URL de l'API ci-dessus (localhost / « dev » → dev, sinon prod)</>
: <>forcé manuellement</>}
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={manualLabel !== null}
onChange={e => setManualLabel(e.target.checked ? detectedLabel : null)}
style={{ width: 'auto' }} />
Forcer manuellement (URL ambiguë, ex. domaine de test sans « dev » dans le nom)
</label>
{manualLabel !== null && (
<select value={manualLabel} onChange={e => setManualLabel(e.target.value)} style={{ marginTop: 8 }}>
<option value="dev">dev</option>
<option value="prod">prod</option>
</select>
)}
</div>
</div>
<p style={{ margin: '-8px 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
prod accessibles simultanément, répétez ces étapes une deuxième fois avec une URL d'API pointant
vers l'autre environnement (et une clé API distincte) — l'étiquette et la clé de config
(<code>{serverKey}</code> ci-dessous) s'ajustent automatiquement. Elle apparaît dans le titre et
la description de chaque outil, pour que l'agent ne confonde jamais les deux portefeuilles.
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans Claude Desktop : Réglages → Développeur → Serveurs MCP locaux → <strong>Modifier la config</strong>.
Ce bouton ouvre le bon fichier quelle que soit votre installation — le chemin diffère en effet
selon que Claude Desktop vient de claude.ai (<code>%APPDATA%\Claude\claude_desktop_config.json</code>)
ou du Microsoft Store (dossier virtualisé sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\</code>).
Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement l'entrée
<code>"{serverKey}"</code> ci-dessous sans écraser le reste ; sinon collez le bloc entier.
</p>
<img
src="/mcp/claude-desktop-developer-settings.png"
alt="Claude Desktop Réglages Développeur Serveurs MCP locaux Modifier la config"
style={{ width: '100%', maxWidth: 520, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 auto 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<CopyBlock text={configJson} />
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Quittez complètement l'application (pas juste fermer la fenêtre) puis rouvrez-la.
</p>
<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)' }}>
Dans la liste des outils MCP de Claude Desktop, les 7 outils <code>crowdlending_*</code> doivent
apparaître. Testez avec une question du type « Quel est mon encours de crowdlending actuellement ? ».
</p>
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */ /* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() { export default function MonCompte() {
const { search } = useLocation(); const { search } = useLocation();
@@ -1100,6 +1288,7 @@ export default function MonCompte() {
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> }, { id: 'profil', label: 'Mon compte', icon: <IconUser /> },
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> }, { id: 'securite', label: 'Sécurité', icon: <IconLock /> },
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> }, { id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
]; ];
return ( return (
@@ -1125,6 +1314,7 @@ export default function MonCompte() {
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>} {section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>} {section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
{section === 'api-keys' && <ApiKeysSection />} {section === 'api-keys' && <ApiKeysSection />}
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
</div> </div>
</div> </div>
+181
View File
@@ -0,0 +1,181 @@
# crowdlending-mcp-server
Serveur MCP local (stdio) 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.
## Prérequis
- Node.js ≥ 18 (fetch natif requis)
- Une clé API générée dans l'app : **Mon compte → Clés API → Nouvelle clé**
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
instance en production)
## Installation
```
cd mcp-server
npm install
```
## Configuration
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.
```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 ».
## 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 :
```
CROWDLENDING_API_KEY=clk_live_... npm run inspect
```
## Outils exposés
Tous en lecture seule (`readOnlyHint: true`) :
| Outil | Description |
|---|---|
| `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé |
| `crowdlending_get_dashboard` | Synthèse KPI : capital investi, capital en risque, intérêts perçus (filtrable par année), cash |
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
| `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` | Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous |
## Lire une annonce de projet (`crowdlending_fetch_url`)
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
cookies sont éliminés automatiquement.
Il ne fait **aucune extraction métier** côté serveur (pas de tentative de
deviner taux/montant/durée par regex) : c'est l'agent qui lit le texte
retourné et en extrait les informations pertinentes dans la conversation,
pour vous les proposer avant toute saisie. Cohérent avec le reste du
serveur : lecture seule, aucune création automatique d'investissement (l'API
v1 n'a pas de capacité d'écriture).
Exemple d'usage : *« Regarde cette page et propose-moi les infos pour créer
l'investissement : https://plateforme.fr/projets/xxx »*.
Garde-fous :
- http/https uniquement, pages HTML uniquement.
- Hôtes locaux/privés bloqués (`localhost`, `127.0.0.1`, plages `10.x`/`172.16-31.x`/`192.168.x`...) —
l'outil ne peut pas cibler votre réseau local, y compris votre propre backend.
- Texte tronqué à 8000 caractères sur les pages très longues (le champ
`truncated` de la réponse l'indique).
## 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.
- **`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`).
+381
View File
@@ -0,0 +1,381 @@
#!/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';
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;
}
/** Formate le résultat d'un outil : texte JSON lisible + structuredContent.
* Le protocole MCP exige que `structuredContent` soit un objet JSON (pas un
* tableau brut) — les endpoints qui renvoient une liste (investissements,
* remboursements, dépôts/retraits) sont donc enveloppés dans { items: [...] }.
* Le texte lisible (`content`), lui, reste le JSON brut tel que renvoyé par
* l'API, tableau ou objet. */
function toolResult(data) {
const structuredContent = Array.isArray(data) ? { items: data } : data;
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent,
};
}
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */
function toolError(e) {
return {
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
isError: true,
};
}
/* ── 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})` : ''}`;
server.registerTool(
'crowdlending_get_investisseur',
{
title: withLabel('Profil investisseur'),
description: withSource(
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
"quel portefeuille portent les autres outils."),
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async () => {
try { return toolResult(await apiGet('/investisseur')); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_dashboard',
{
title: withLabel('Synthèse du portefeuille'),
description: withSource(
"Retourne les KPIs du portefeuille : nombre d'investissements, total " +
"investi, capital investi actuel (= capital restant dû sur les prêts en " +
"cours et en défaut, net des remboursements déjà perçus — équivalent au " +
"KPI \"Capital investi\" de l'app), capital en risque (sous-ensemble en " +
"retard/procédure), montant remboursé, intérêts bruts/nets perçus, " +
"capital reçu, total dépôts/retraits. Les montants d'investissements sont " +
"des soldes actuels (photo à aujourd'hui), pas des cumuls par période. " +
"Point d'entrée idéal pour une vue d'ensemble avant d'aller chercher le détail."),
inputSchema: {
annee: z.number().int().optional()
.describe("Filtre les intérêts/capital reçu sur une année (ex. 2026). Omettre pour le cumul total. N'affecte pas le capital investi/en risque, qui sont toujours des soldes actuels."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ annee }) => {
try { return toolResult(await apiGet('/dashboard', { annee })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_investissements',
{
title: withLabel('Liste des investissements'),
description: withSource(
"Liste les investissements (prêts participatifs) du portefeuille : " +
"projet, émetteur, plateforme, montant investi, taux, durée, statut. " +
"Filtrable par statut. Ne renvoie pas les remboursements détaillés — " +
"utiliser crowdlending_get_investissement pour le détail d'un projet."),
inputSchema: {
statut: z.enum(['en_cours', 'rembourse', 'en_retard', 'procedure', 'cloture'])
.optional()
.describe("Filtrer par statut. Omettre pour lister tous les investissements."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ statut }) => {
try { return toolResult(await apiGet('/investissements', { statut })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_investissement',
{
title: withLabel("Détail d'un investissement"),
description: withSource(
"Retourne le détail complet d'un investissement (identifié par son id, " +
"obtenu via crowdlending_list_investissements), y compris la liste de " +
"ses remboursements réels perçus (capital, intérêts bruts/nets, cashback)."),
inputSchema: {
id: z.number().int().positive().describe("Identifiant de l'investissement"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
try { return toolResult(await apiGet(`/investissements/${id}`)); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_remboursements',
{
title: withLabel('Liste des remboursements'),
description: withSource(
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
"avec le nom du projet et de la plateforme. Filtrable par période."),
inputSchema: {
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de début au format YYYY-MM-DD (incluse)'),
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ date_debut, date_fin }) => {
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_depots_retraits',
{
title: withLabel('Liste des dépôts / retraits'),
description: withSource(
"Liste les mouvements de cash (dépôts et retraits) sur les plateformes " +
"de crowdlending, du plus récent au plus ancien."),
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async () => {
try { return toolResult(await apiGet('/depots-retraits')); }
catch (e) { return toolError(e); }
},
);
/* ── 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);
});
+1685
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "crowdlending-mcp-server",
"version": "0.1.0",
"description": "Serveur MCP local (stdio) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...).",
"type": "module",
"main": "index.js",
"bin": {
"crowdlending-mcp-server": "./index.js"
},
"scripts": {
"start": "node index.js",
"inspect": "npx @modelcontextprotocol/inspector node index.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"jsdom": "^29.1.1",
"zod": "^3.25.0"
}
}