API V1 Public + preparation serveur MCP
This commit is contained in:
@@ -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é.
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Generated
+1685
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user