API V1 Public + preparation serveur MCP
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
* get:
|
||||
* 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]
|
||||
* 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:
|
||||
* 200: { description: Synthèse KPI }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
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(`
|
||||
SELECT
|
||||
COUNT(*) AS nb_investissements,
|
||||
COALESCE(SUM(montant_investi), 0) AS total_investi,
|
||||
COALESCE(SUM(CASE WHEN statut='en_cours' THEN montant_investi END), 0) AS encours,
|
||||
COALESCE(SUM(CASE WHEN statut='rembourse' THEN montant_investi END), 0) AS rembourse
|
||||
FROM investissements WHERE investisseur_id = ?
|
||||
COUNT(*) AS nb_investissements,
|
||||
COALESCE(SUM(i.montant_investi), 0) AS total_investi,
|
||||
COALESCE(SUM(CASE WHEN i.statut IN ('en_cours','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_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);
|
||||
|
||||
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(`
|
||||
SELECT
|
||||
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
|
||||
FROM remboursements r
|
||||
JOIN investissements i ON i.id = r.investissement_id
|
||||
WHERE i.investisseur_id = ?
|
||||
`).get(invId);
|
||||
WHERE ${interetsConds.join(' AND ')}
|
||||
`).get(...interetsParams);
|
||||
|
||||
const cash = db.prepare(`
|
||||
SELECT
|
||||
@@ -43,7 +76,7 @@ router.get('/', (req, res) => {
|
||||
FROM depots_retraits WHERE investisseur_id = ?
|
||||
`).get(invId);
|
||||
|
||||
res.json({ investissements, interets, cash });
|
||||
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -22,6 +22,9 @@ function IconKey() {
|
||||
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>;
|
||||
}
|
||||
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 ────────────────────────────── */
|
||||
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 ─────────────────────────────────────────── */
|
||||
export default function MonCompte() {
|
||||
const { search } = useLocation();
|
||||
@@ -1100,6 +1288,7 @@ export default function MonCompte() {
|
||||
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
|
||||
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
|
||||
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
|
||||
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1125,6 +1314,7 @@ export default function MonCompte() {
|
||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||
{section === 'api-keys' && <ApiKeysSection />}
|
||||
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
||||
</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