Compare commits

...

5 Commits

Author SHA1 Message Date
ocroguennec 41c464e6a3 Améliorations diverses 2026-07-20 22:12:49 +02:00
ocroguennec 441da1975a Possibilité d'avoir une clé API Globale par famille 2026-07-20 18:47:58 +02:00
ocroguennec 9eb19efd92 MAj MCP Serveur 2026-07-20 17:30:08 +02:00
ocroguennec 6e58731a20 Fix 2026-07-15 23:08:36 +02:00
ocroguennec 56dd1f89bd Update MCP server 2026-07-15 22:59:44 +02:00
40 changed files with 1748 additions and 590 deletions
+22
View File
@@ -1960,6 +1960,10 @@ console.log('[DB] Migrations 2FA OK');
db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8"); db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8");
console.log('[DB] Colonne smtp_config.min_password_length ajoutée'); console.log('[DB] Colonne smtp_config.min_password_length ajoutée');
} }
if (!cols.includes('mcp_url')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN mcp_url TEXT NOT NULL DEFAULT ''");
console.log('[DB] Colonne smtp_config.mcp_url ajoutée');
}
// ── Migration : email sur investisseurs ─────────────────────────────── // ── Migration : email sur investisseurs ───────────────────────────────
const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name); const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name);
@@ -2279,4 +2283,22 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)'); db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
// ── Migration : clés API à scope "Famille et entreprises" ───────────────
// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt
// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le
// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la
// contrainte existante : quand scope_all=1, la route de création force
// investisseur_id à pointer vers l'investisseur principal (ancrage FK),
// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un
// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et
// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1
// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI).
{
const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!cols.includes('scope_all')) {
db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0');
console.log('[DB] api_keys: colonne scope_all ajoutée');
}
}
export default db; export default db;
+14 -5
View File
@@ -3,9 +3,16 @@ import db from '../db/index.js';
/** /**
* Authentification par clé API (X-API-Key), distincte du JWT utilisé par le * Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
* frontend (requireAuth). Une clé API est toujours scopée à un seul * frontend (requireAuth). Une clé API est scopée soit à un seul investisseur,
* investisseur — pas de notion de "scope=all" ici, contrairement aux routes * soit à "Famille et entreprises" (scope_all=1, réservé au profil principal
* internes. Réservé aux routes /api/v1 (lecture seule, Phase 1). * — voir routes/apiKeys.js) — en miroir du scope=all des routes internes.
* Réservé aux routes /api/v1 (lecture seule, Phase 1).
*
* Expose sur `req` :
* - req.investisseurId : id de l'investisseur si scope unique, sinon null
* - req.investisseurScopeAll: true si la clé couvre tout le foyer
* - req.userId : user_id du titulaire de la clé (toujours défini,
* utile pour le filtre "tous les investisseurs" quand scope_all)
*/ */
export function requireApiKey(req, res, next) { export function requireApiKey(req, res, next) {
const key = req.header('X-API-Key'); const key = req.header('X-API-Key');
@@ -15,7 +22,7 @@ export function requireApiKey(req, res, next) {
const hash = crypto.createHash('sha256').update(key).digest('hex'); const hash = crypto.createHash('sha256').update(key).digest('hex');
const row = db.prepare(` const row = db.prepare(`
SELECT k.id, k.investisseur_id, k.scopes, k.revoked_at SELECT k.id, k.user_id, k.investisseur_id, k.scope_all, k.scopes, k.revoked_at
FROM api_keys k FROM api_keys k
WHERE k.key_hash = ? WHERE k.key_hash = ?
`).get(hash); `).get(hash);
@@ -27,7 +34,9 @@ export function requireApiKey(req, res, next) {
db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id); db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
req.apiKeyId = row.id; req.apiKeyId = row.id;
req.investisseurId = row.investisseur_id; req.userId = row.user_id;
req.investisseurScopeAll = !!row.scope_all;
req.investisseurId = req.investisseurScopeAll ? null : row.investisseur_id;
req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim()); req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
next(); next();
} }
+16 -8
View File
@@ -17,7 +17,7 @@ function generateKey() {
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */ /* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id JOIN investisseurs i ON i.id = k.investisseur_id
@@ -27,29 +27,37 @@ router.get('/', (req, res) => {
res.json(rows); res.json(rows);
}); });
/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur) ──── */ /* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur, ou
scope_all pour "Famille et entreprises") ──────────────────────────────
scope_all=true n'est autorisé que si investisseur_id désigne le profil
principal — enforcement serveur, indépendant de ce que montre l'UI, pour
qu'un appel direct à l'API ne puisse pas contourner cette règle. */
router.post('/', (req, res, next) => { router.post('/', (req, res, next) => {
try { try {
const nom = (req.body?.nom || '').trim(); const nom = (req.body?.nom || '').trim();
const investisseur_id = Number(req.body?.investisseur_id); const investisseur_id = Number(req.body?.investisseur_id);
const scope_all = !!req.body?.scope_all;
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis'); if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)'); if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis'); if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis');
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') const inv = db.prepare('SELECT id, is_principal FROM investisseurs WHERE id = ? AND user_id = ?')
.get(investisseur_id, req.user.id); .get(investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable'); if (!inv) throw new HttpError(404, 'Investisseur introuvable');
if (scope_all && !inv.is_principal) {
throw new HttpError(403, 'Seul le profil principal peut créer une clé « Famille et entreprises »');
}
const { full, hash, prefix } = generateKey(); const { full, hash, prefix } = generateKey();
const info = db.prepare(` const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes) INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all)
VALUES (?, ?, ?, ?, ?, 'read') VALUES (?, ?, ?, ?, ?, 'read', ?)
`).run(req.user.id, investisseur_id, nom, prefix, hash); `).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0);
const saved = db.prepare(` const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ? WHERE k.id = ?
@@ -74,7 +82,7 @@ router.patch('/:id', (req, res, next) => {
db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id); db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
const saved = db.prepare(` const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ? WHERE k.id = ?
+6 -2
View File
@@ -29,10 +29,11 @@ function ensureRow() {
router.get('/', (_req, res, next) => { router.get('/', (_req, res, next) => {
try { try {
ensureRow(); ensureRow();
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
res.json({ res.json({
appName: row.app_name || 'Crowdlending Tracker', appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '', appUrl: row.app_url || '',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0, allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8, minPasswordLength: row.min_password_length || 8,
}); });
@@ -42,6 +43,7 @@ router.get('/', (_req, res, next) => {
const PatchSchema = z.object({ const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(), appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(), appUrl: z.string().max(500).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(), allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(), minPasswordLength: z.number().int().min(6).max(64).optional(),
}); });
@@ -50,18 +52,20 @@ router.patch('/', (req, res, next) => {
try { try {
ensureRow(); ensureRow();
const body = PatchSchema.parse(req.body); const body = PatchSchema.parse(req.body);
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
db.prepare(` db.prepare(`
UPDATE smtp_config SET UPDATE smtp_config SET
app_name = ?, app_name = ?,
app_url = ?, app_url = ?,
mcp_url = ?,
allow_registration = ?, allow_registration = ?,
min_password_length = ? min_password_length = ?
WHERE id = 1 WHERE id = 1
`).run( `).run(
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'), body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''), body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0), body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8), body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
); );
+15 -7
View File
@@ -27,9 +27,17 @@ const router = Router();
* 200: { description: Synthèse KPI } * 200: { description: Synthèse KPI }
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const invId = req.investisseurId;
const annee = req.query.annee ? Number(req.query.annee) : null; const annee = req.query.annee ? Number(req.query.annee) : null;
// Clé "Famille et entreprises" (scope_all) → agrège tous les investisseurs
// du foyer (req.userId) ; clé mono-investisseur → filtre sur req.investisseurId.
// Dans les deux cas un seul paramètre suffit : soit l'id investisseur, soit
// le user_id pour la sous-requête IN (…) — en miroir de ?scope=all côté JWT.
const invCond = (col) => req.investisseurScopeAll
? `${col} IN (SELECT id FROM investisseurs WHERE user_id = ?)`
: `${col} = ?`;
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
// ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital // ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours + // en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
// en_defaut). "capital_investi" et "capital_en_risque" sont des soldes // en_defaut). "capital_investi" et "capital_en_risque" sont des soldes
@@ -51,11 +59,11 @@ router.get('/', (req, res) => {
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 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, END), 0) AS capital_en_risque,
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
FROM investissements i WHERE i.investisseur_id = ? FROM investissements i WHERE ${invCond('i.investisseur_id')}
`).get(invId); `).get(invParam);
const interetsConds = ['i.investisseur_id = ?']; const interetsConds = [invCond('i.investisseur_id')];
const interetsParams = [invId]; const interetsParams = [invParam];
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); } if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
const interets = db.prepare(` const interets = db.prepare(`
@@ -73,8 +81,8 @@ router.get('/', (req, res) => {
SELECT SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots, COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
FROM depots_retraits WHERE investisseur_id = ? FROM depots_retraits WHERE ${invCond('investisseur_id')}
`).get(invId); `).get(invParam);
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash }); res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
}); });
+9 -2
View File
@@ -14,14 +14,21 @@ const router = Router();
* 200: { description: Liste des mouvements } * 200: { description: Liste des mouvements }
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const invCond = req.investisseurScopeAll
? 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'dr.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const rows = db.prepare(` const rows = db.prepare(`
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type, SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
dr.montant, dr.libelle dr.montant, dr.libelle
FROM depots_retraits dr FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id JOIN plateformes p ON p.id = dr.plateforme_id
WHERE dr.investisseur_id = ? WHERE ${invCond}
ORDER BY dr.date_operation DESC ORDER BY dr.date_operation DESC
`).all(req.investisseurId); `).all(invParam);
res.json(rows); res.json(rows);
}); });
+13 -4
View File
@@ -27,8 +27,12 @@ const LIST_COLUMNS = `
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { statut } = req.query; const { statut } = req.query;
const conds = ['i.investisseur_id = ?']; // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
const args = [req.investisseurId]; // foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (statut) { conds.push('i.statut = ?'); args.push(statut); } if (statut) { conds.push('i.statut = ?'); args.push(statut); }
const rows = db.prepare(` const rows = db.prepare(`
@@ -59,12 +63,17 @@ router.get('/', (req, res) => {
*/ */
router.get('/:id', (req, res, next) => { router.get('/:id', (req, res, next) => {
try { try {
const invCond = req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const inv = db.prepare(` const inv = db.prepare(`
SELECT ${LIST_COLUMNS}, i.notes SELECT ${LIST_COLUMNS}, i.notes
FROM investissements i FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ? AND i.investisseur_id = ? WHERE i.id = ? AND ${invCond}
`).get(req.params.id, req.investisseurId); `).get(req.params.id, invParam);
if (!inv) throw new HttpError(404, 'Investissement introuvable'); if (!inv) throw new HttpError(404, 'Investissement introuvable');
const remboursements = db.prepare(` const remboursements = db.prepare(`
+16 -2
View File
@@ -7,16 +7,30 @@ const router = Router();
* @openapi * @openapi
* /investisseur: * /investisseur:
* get: * get:
* summary: Profil de l'investisseur lié à la clé API * summary: Profil investisseur (ou liste, pour une clé "Famille et entreprises")
* description: >
* Avec une clé scopée à un seul investisseur, renvoie son profil (objet).
* Avec une clé "Famille et entreprises" (scope_all), renvoie la liste des
* investisseurs du foyer (tableau) — il n'y a plus un profil unique à
* renvoyer.
* tags: [Investisseur] * tags: [Investisseur]
* security: [{ ApiKeyAuth: [] }] * security: [{ ApiKeyAuth: [] }]
* responses: * responses:
* 200: * 200:
* description: Profil investisseur * description: Profil investisseur, ou liste de profils si scope_all
* 401: * 401:
* description: Clé API invalide ou manquante * description: Clé API invalide ou manquante
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
if (req.investisseurScopeAll) {
const investisseurs = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE user_id = ?
ORDER BY is_principal DESC, id ASC
`).all(req.userId);
return res.json(investisseurs);
}
const inv = db.prepare(` const inv = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE id = ? FROM investisseurs WHERE id = ?
+6 -2
View File
@@ -22,8 +22,12 @@ const router = Router();
*/ */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query; const { date_debut, date_fin } = req.query;
const conds = ['i.investisseur_id = ?']; // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
const args = [req.investisseurId]; // foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); } if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); } if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
+3 -2
View File
@@ -94,15 +94,16 @@ app.get('/api/app-info', (_, res) => {
try { try {
const cfg = getSmtpConfig(); const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
res.json({ res.json({
appName: cfg.appName || 'Crowdlending Tracker', appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true, allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8, minPasswordLength: row ? (row.min_password_length || 8) : 8,
mcpUrl: row ? (row.mcp_url || '') : '',
}); });
} catch { } catch {
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 }); res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' });
} }
}); });
+12 -6
View File
@@ -49,12 +49,13 @@ services:
- internal - internal
- backend # réseau Traefik - backend # réseau Traefik
# Serveur MCP distant (Phase 5) — accessible publiquement sur un # Serveur MCP (Phase 5) — même image/code que le serveur utilisé en
# sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au # développement local (mcp-server/server.js, npm run dev), déployé ici
# reste de l'app, ce service est volontairement ouvert à des utilisateurs # accessible publiquement sur un sous-domaine dédié, SANS le middleware
# distants qui ne peuvent pas déployer le serveur MCP local. La clé API # ipwhitelist-all : contrairement au reste de l'app, ce service est
# (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE # volontairement ouvert à des utilisateurs distants qui ne peuvent pas
# barrière d'accès — voir mcp-server/http-server.js. # faire tourner le serveur en local. La clé API (en-tête X-API-Key, propre
# à chaque utilisateur) est donc la SEULE barrière d'accès.
crowdlending-mcp: crowdlending-mcp:
build: build:
context: ./mcp-server context: ./mcp-server
@@ -66,6 +67,11 @@ services:
PORT: 4100 PORT: 4100
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1 CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost
MCP_LABEL: prod
# Explicitement désactivé : ce serveur sert des utilisateurs distants
# non maîtrisés, l'outil de lecture d'URL arbitraire (SSRF) reste
# réservé au développement local. Ne pas passer à true ici.
MCP_ENABLE_FETCH_URL: "false"
depends_on: depends_on:
crowdlending-backend: crowdlending-backend:
condition: service_healthy condition: service_healthy
+241
View File
@@ -8,6 +8,7 @@
"name": "crowdlending-frontend", "name": "crowdlending-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@@ -252,6 +253,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1159,6 +1169,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1189,6 +1219,15 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.25", "version": "2.10.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
@@ -1257,6 +1296,26 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": { "node_modules/cfb": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
@@ -1286,6 +1345,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1298,6 +1369,15 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1316,6 +1396,15 @@
} }
} }
}, },
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.349", "version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -1372,6 +1461,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/frac": { "node_modules/frac": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -1406,6 +1512,36 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1438,6 +1574,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1493,6 +1646,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1529,6 +1705,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1596,6 +1782,23 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.2", "version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
@@ -1682,6 +1885,35 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1713,6 +1945,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
+112
View File
@@ -0,0 +1,112 @@
# Power Query — exemples de connexion à l'API v1
Exemples de requêtes [Power Query](https://learn.microsoft.com/fr-fr/power-query/) (Excel)
pour interroger le portefeuille crowdlending depuis un classeur Excel, en s'appuyant sur
la même API v1 (lecture seule) que le [serveur MCP](../mcp-server/README.md).
Aucune écriture : ces requêtes ne font que lire des données, comme le serveur MCP.
## Pré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, ex. `https://crowdlending.croguennec.net`)
- Excel avec Power Query (Excel 365 / 2016+ sous Windows ou Mac — Power Query est intégré
nativement, rien à installer)
## Option 1 — Classeur prêt à l'emploi (`crowdlending-powerquery-exemple.xlsb`)
Le plus rapide : ouvrez `crowdlending-powerquery-exemple.xlsb` directement dans Excel.
> **Pourquoi un `.xlsb` et pas un `.xlsx` ?** Le format binaire d'Excel (`.xlsb`) est celui
> à partir duquel ce classeur a pu être construit et vérifié de façon fiable en dehors
> d'Excel. Il s'ouvre et se comporte exactement comme un `.xlsx` — Power Query, tableaux,
> actualisation, tout fonctionne à l'identique. Si vous préférez un `.xlsx`, ouvrez le
> fichier puis **Fichier → Enregistrer sous** et changez le format ; les requêtes suivent.
Le classeur contient :
| Requête | Rôle |
|---|---|
| `ApiBaseUrl` | URL de l'API à interroger (à modifier) |
| `ApiKey` | Votre clé API (à modifier — voir ci-dessous) |
| `fnApiGet` | Fonction utilitaire partagée (appel HTTP + parsing JSON), utilisée par toutes les requêtes de données |
| `Investisseur` | Profil investisseur (ou liste des membres si clé « Famille et entreprises ») |
| `Dashboard` | KPIs du portefeuille (capital investi, capital en risque, intérêts, cash), au format « Indicateur / Valeur » |
| `Investissements` | Liste des investissements |
| `Remboursements` | Historique des remboursements |
| `DepotsRetraits` | Historique des mouvements de cash |
| `fnDetailInvestissement` | Fonction avancée : détail + remboursements d'un investissement par id |
| `Test` | Table d'instructions (« À lire avant de commencer ») — pas une donnée métier |
Étapes :
1. Ouvrez le classeur. La feuille affiche par défaut un exemple mis en cache (pas encore
vos données) — c'est normal, Excel n'a pas encore appelé l'API.
2. **Données → Requêtes et connexions**. Repérez `ApiKey` dans le volet à droite.
3. Clic droit sur `ApiKey`**Modifier**. Dans l'éditeur, remplacez
`"clk_live_VOTRE_CLE_API"` par votre vraie clé (entre guillemets), puis
**Fermer et charger**.
4. Vérifiez `ApiBaseUrl` de la même façon : `http://localhost:4000/api/v1` en local, ou
`https://crowdlending.croguennec.net/api/v1` en production (adaptez à votre domaine).
5. **Données → Actualiser tout** (ou Ctrl+Alt+F5). La table « Test » se met à jour avec les
instructions, signe que la connexion fonctionne.
6. Pour chaque requête de données qui vous intéresse (`Investissements`, `Remboursements`…) :
clic droit dans le volet **Requêtes et connexions****Charger dans…** → choisissez
Tableau (ou Tableau croisé dynamique) et la feuille de destination.
## Option 2 — Coller les requêtes manuellement (`queries/*.pq`)
Utile si vous préférez tout construire vous-même dans un classeur existant, ou si une
requête du classeur ne s'affiche pas correctement chez vous.
Pour chaque fichier, dans l'ordre ci-dessous : **Données → Obtenir des données → À partir
d'autres sources → Requête vide**, renommez la requête (volet de droite, ou après double-clic
sur son nom) avec **exactement** le nom du fichier (sans `.pq`), puis **Accueil → Éditeur
avancé**, effacez le contenu par défaut, collez le contenu du fichier, **Terminé**.
Ordre à respecter (chaque requête réutilise les précédentes par leur nom) :
1. `ApiBaseUrl.pq` — modifiez l'URL avant de coller si besoin
2. `ApiKey.pq` — remplacez `VOTRE_CLE_API` par votre clé avant de coller
3. `fnApiGet.pq`
4. `Investisseur.pq`, `Dashboard.pq`, `Investissements.pq`, `Remboursements.pq`,
`DepotsRetraits.pq` — dans l'ordre que vous voulez
5. `fnDetailInvestissement.pq` (optionnel, usage avancé — voir plus bas)
Une fois `fnApiGet` créée, **Fermer et charger** chaque requête de données individuellement
(clic droit → Charger dans…) pour l'ajouter comme tableau.
## Filtres
Les requêtes `Dashboard`, `Investissements` et `Remboursements` acceptent des filtres côté
API (année, statut, période). Par défaut elles ramènent tout (`null`). Pour filtrer,
ouvrez la requête dans l'éditeur avancé et remplacez `null` par le paramètre indiqué en
commentaire en tête du fichier `.pq` correspondant, par exemple :
```
Source = fnApiGet("/investissements", [statut = "rembourse"])
```
Statuts possibles : `en_cours`, `rembourse`, `en_retard`, `procedure`, `cloture`.
## Usage avancé — détail d'un investissement par ligne
`fnDetailInvestissement` permet de ramener, pour chaque ligne de la table `Investissements`,
le détail complet (dont les remboursements) sans requête séparée : sur la table
`Investissements`, **Ajout de colonne → Colonne personnalisée**, formule
`= fnDetailInvestissement([id])`. Attention : ceci déclenche un appel API par ligne — à
réserver à un nombre raisonnable d'investissements (quelques dizaines).
## Dépannage
- **`Impossible de se connecter au service distant`** — le backend n'est pas démarré, ou
`ApiBaseUrl` est incorrecte (vérifiez le port et le suffixe `/api/v1`).
- **`Erreur API (401)` / `Clé API invalide ou révoquée`** — régénérez une clé dans
Mon compte → Clés API et remplacez la valeur de `ApiKey`.
- **Une requête du classeur n'apparaît pas dans le volet, ou affiche une erreur au premier
chargement** — repartez de l'Option 2 pour cette requête précise : créez une requête
vide portant son nom et collez le contenu du `.pq` correspondant.
- **Avertissement de sécurité / niveau de confidentialité au premier chargement** — normal
pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou
« Public » selon votre contexte, ce n'est pas spécifique à ce classeur.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,3 @@
// URL de base de l'API v1. En local : http://localhost:4000/api/v1
// En production : https://votre-domaine/api/v1
"http://localhost:4000/api/v1"
@@ -0,0 +1,2 @@
// Clé API générée dans l'app : Mon compte > Clés API > Nouvelle clé
"clk_live_VOTRE_CLE_API"
@@ -0,0 +1,14 @@
let
// Pour filtrer sur une année, remplacez null par [annee = "2026"]
Source = fnApiGet("/dashboard", null),
VersLignes = (enregistrement as record, nomSection as text) =>
Table.AddColumn(Record.ToTable(enregistrement), "Section", each nomSection),
Combine = Table.Combine({
VersLignes(Source[investissements], "Investissements"),
VersLignes(Source[interets], "Interets"),
VersLignes(Source[cash], "Cash")
}),
Reordonne = Table.ReorderColumns(Combine, {"Section", "Name", "Value"}),
Resultat = Table.RenameColumns(Reordonne, {{"Name", "Indicateur"}, {"Value", "Valeur"}})
in
Resultat
@@ -0,0 +1,5 @@
let
Source = fnApiGet("/depots-retraits", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,7 @@
let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,6 @@
let
Source = fnApiGet("/investisseur", null),
// Clé "Famille et entreprises" -> liste ; clé mono-investisseur -> objet unique
Resultat = if Value.Is(Source, type list) then Table.FromRecords(Source) else Table.FromRecords({Source})
in
Resultat
@@ -0,0 +1,6 @@
let
// Pour filtrer par période, remplacez null par [date_debut = "2026-01-01", date_fin = "2026-12-31"]
Source = fnApiGet("/remboursements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,19 @@
(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat
@@ -0,0 +1,5 @@
// Astuce avancée : sur la table Investissements, colonne personnalisée
// "= fnDetailInvestissement([id])" pour ramener le détail + les remboursements
// de chaque prêt (Ajout de colonne > Colonne personnalisée).
(id as number) as record =>
fnApiGet("/investissements/" & Text.From(id), null)
+498 -11
View File
@@ -1,21 +1,107 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { withDevOverrides } from '../utils/devOverrides.js';
/** Nom de fichier à partir de la question (slug, sans accents). */
function slugify(text) {
return text
.toLowerCase()
.normalize('NFD').replace(new RegExp('[\\u0300-\\u036f]', 'g'), '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
/* ── Accordéon FAQ ───────────────────────────────────────────── */ /* ── Accordéon FAQ ───────────────────────────────────────────── */
function FaqItem({ question, children }) { /* `openQuestion`/`setOpenQuestion` sont partagés par toutes les FaqItem d'une
const [open, setOpen] = useState(false); même page (état remonté dans le composant parent) : une seule question
ouverte à la fois, ouvrir la suivante referme automatiquement les autres. */
function FaqItem({ question, children, openQuestion, setOpenQuestion }) {
const open = openQuestion === question;
const toggle = () => setOpenQuestion(open ? null : question);
const [pdfState, setPdfState] = useState('idle'); // idle | generating | error
const contentRef = useRef(null);
const handleDownloadPdf = async () => {
if (!contentRef.current || pdfState === 'generating') return;
setPdfState('generating');
// Insère le titre directement dans le bloc réel (contentRef), le temps de
// la capture uniquement, puis le retire — évite de le dupliquer en
// permanence à l'écran tout en réutilisant l'élément réellement rendu
// (un conteneur hors-écran séparé est capturé vide par html2canvas :
// sa zone de rendu ne suit pas un élément poussé loin hors du viewport).
let heading = null;
try {
const { default: html2pdf } = await import('html2pdf.js');
// Fond capturé = fond réel du bloc (var(--surface)) : reste cohérent
// que le thème actif soit clair ou sombre, plutôt qu'un blanc forcé
// qui casserait le contraste du texte en mode sombre.
const bgColor = getComputedStyle(contentRef.current).backgroundColor || '#ffffff';
heading = document.createElement('h3');
heading.textContent = question;
heading.style.margin = '0 0 12px';
heading.style.color = 'var(--text)';
heading.style.fontSize = '1.1rem';
contentRef.current.insertBefore(heading, contentRef.current.firstChild);
await html2pdf()
.set({
margin: 28,
filename: `faq-${slugify(question) || 'crowdlending'}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: bgColor },
jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' },
// 'avoid-all' : ne coupe jamais un élément (image, bloc de code,
// ligne de tableau…) au milieu — le pousse entièrement sur la page
// suivante à la place. C'est ce qui manquait avec le moteur HTML
// interne de jsPDF (doc.html()), qui tranchait au hasard.
pagebreak: { mode: ['avoid-all', 'css'] },
})
.from(contentRef.current)
.toPdf()
.get('pdf')
.then((pdf) => {
// Numérotation en bas à droite, une fois le nombre total de pages connu.
const total = pdf.internal.getNumberOfPages();
for (let i = 1; i <= total; i++) {
pdf.setPage(i);
pdf.setFontSize(9);
pdf.setTextColor(150);
pdf.text(
`${i} / ${total}`,
pdf.internal.pageSize.getWidth() - 28,
pdf.internal.pageSize.getHeight() - 16,
{ align: 'right' }
);
}
})
.save();
setPdfState('idle');
} catch (e) {
console.error('Échec de la génération du PDF :', e);
setPdfState('error');
} finally {
if (heading && heading.parentNode) heading.parentNode.removeChild(heading);
}
};
return ( return (
<div style={{ <div style={{
borderBottom: '1px solid var(--border)', background: 'var(--surface)',
padding: '0', border: '1px solid var(--border)',
borderRadius: 10,
boxShadow: 'var(--shadow)',
padding: '0 20px',
marginBottom: 12,
}}> }}>
<button <button
onClick={() => setOpen(o => !o)} onClick={toggle}
style={{ style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none', width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex', padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12, alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500, color: 'var(--text)', fontSize: '1.05rem', fontWeight: 600,
}} }}
> >
<span>{question}</span> <span>{question}</span>
@@ -29,16 +115,81 @@ function FaqItem({ question, children }) {
</button> </button>
{open && ( {open && (
<div style={{ <div style={{
paddingBottom: 16, color: 'var(--text-muted)', paddingBottom: 20, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7, fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}> }}>
<div ref={contentRef} style={{ background: 'var(--surface)' }}>
{children} {children}
</div> </div>
<button
onClick={handleDownloadPdf}
disabled={pdfState === 'generating'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
padding: '7px 14px', border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--surface-2)', color: 'var(--text)',
fontSize: 'var(--fs-sm)', fontWeight: 500,
cursor: pdfState === 'generating' ? 'default' : 'pointer',
opacity: pdfState === 'generating' ? 0.6 : 1,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{pdfState === 'generating' ? 'Génération du PDF…' : 'Télécharger cette FAQ en PDF'}
</button>
{pdfState === 'error' && (
<p style={{ color: 'var(--danger)', fontSize: 'var(--fs-sm)', margin: '8px 0 0' }}>
La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez
« Enregistrer en PDF ».
</p>
)}
</div>
)} )}
</div> </div>
); );
} }
/* ── Titre de section (regroupement thématique de la FAQ) ──────── */
function FaqSectionTitle({ children, first }) {
return (
<h3 style={{
margin: first ? '0 0 12px' : '32px 0 12px',
fontSize: '0.8rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.06em', color: 'var(--text-muted)',
}}>
{children}
</h3>
);
}
/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */
function DownloadLink({ href, children }) {
return (
<a
href={href}
download
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
color: 'var(--primary)', fontSize: 'var(--fs-sm)', fontWeight: 500,
textDecoration: 'none', margin: '0 0 12px',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{children}
</a>
);
}
/* ── Navigation ─────────────────────────────────────────────── */ /* ── Navigation ─────────────────────────────────────────────── */
const NAV = [ const NAV = [
{ {
@@ -59,6 +210,17 @@ const NAV = [
export default function Aide() { export default function Aide() {
const { search } = useLocation(); const { search } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [openQuestion, setOpenQuestion] = useState(null); // question ouverte dans la FAQ (une seule à la fois)
const [appInfo, setAppInfo] = useState({}); // { appUrl, mcpUrl } — valeurs brutes de la base, telles quelles
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {});
}, []);
// URL MCP annoncée comme « déjà pré-remplie » : doit refléter ce que
// Mon compte → Serveur MCP affichera réellement, y compris en dev (où
// cette page-là retombe elle-même sur localhost via withDevOverrides).
const mcpConfigUrl = withDevOverrides(appInfo).mcpUrl || 'https://mcp.<votre domaine>/mcp';
const section = new URLSearchParams(search).get('section') || 'faq'; const section = new URLSearchParams(search).get('section') || 'faq';
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true }); const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
@@ -86,9 +248,11 @@ export default function Aide() {
{section === 'faq' && ( {section === 'faq' && (
<div> <div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2> <h2 style={{ marginTop: 0, marginBottom: 24, fontSize: '1.6rem', fontWeight: 700 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?"> <FaqSectionTitle first>Comprendre la plateforme</FaqSectionTitle>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme, Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit : c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
@@ -137,7 +301,7 @@ export default function Aide() {
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p> permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
</FaqItem> </FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?"> <FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement, Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt, sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
@@ -179,6 +343,329 @@ export default function Aide() {
</p> </p>
</FaqItem> </FaqItem>
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le serveur MCP tourne déjà en continu (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
exposé via Traefik) — vous n'avez rien à installer ni à laisser tourner sur votre machine. Il
suffit de connecter Claude Desktop à l'URL publique.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étapes</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Générez une clé API : <strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong> (par exemple nommée « Claude Desktop »).</li>
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Mon compte → Serveur MCP</strong> et renseignez l'URL publique
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{mcpConfigUrl}</code>{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.</li>
<li>Copiez la configuration générée dans <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>claude_desktop_config.json</code> (Réglages
Développeur Serveurs MCP locaux Modifier la config) le mécanisme passe par
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> mcp-remote</code> (avec le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
Windows), généré automatiquement pour vous.</li>
<li>Redémarrez complètement Claude Desktop.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sécurité</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Le point d'entrée public n'a <strong style={{ color: 'var(--text)' }}>volontairement aucune restriction d'IP</strong> (accessible
depuis n'importe , pour un usage nomade) : la clé API est donc la seule barrière d'accès. Traitez-la comme un
mot de passe, et révoquez-la immédiatement en cas de doute (Mon compte → Clés API).</li>
<li>Limite de 60 requêtes/minute par IP (au-delà, erreur 429) et fermeture automatique des sessions inactives
depuis plus de 30 minutes — aucune donnée de session n'est conservée entre deux connexions.</li>
<li>L'outil <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> (lecture d'une page web arbitraire) reste
<strong style={{ color: 'var(--text)' }}> désactivé</strong> sur ce serveur.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même :
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> npx</code> qui échoue silencieusement à joindre le registre npm voir la
section Dépannage de <strong style={{ color: 'var(--text)' }}>Mon compte Serveur MCP</strong>.
</p>
</FaqItem>
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Une fois connecté, Claude (Desktop ou tout autre client MCP) peut consulter votre portefeuille en langage
naturel il choisit lui-même le bon outil selon votre question. Le serveur est <strong style={{ color: 'var(--text)' }}>strictement
en lecture seule</strong> : aucune donnée n'est jamais créée, modifiée ou supprimée depuis une conversation. Toute
saisie (nouvel investissement, remboursement…) reste manuelle dans l'application.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Fonctions disponibles</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal). Avec une clé « Famille et entreprises », renvoie la liste des profils du foyer.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits — filtrable par année.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_investissements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Liste des investissements (projet, émetteur, plateforme, montant, taux, durée, statut), filtrable par statut.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_depots_retraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash (dépôts et retraits), du plus récent au plus ancien.</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
<em>Développement local uniquement</em>, désactivé par défaut. Lit une page web (ex. annonce de projet sur
une plateforme) et en extrait le texte propre c'est à vous d'en reprendre les informations utiles pour
créer l'investissement manuellement, l'outil ne saisit rien lui-même.
</td>
</tr>
</tbody>
</table>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Exemples de questions</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 2 }}>
<li>« Quel est mon encours de crowdlending actuellement ? »</li>
<li>« Quel a été mon rendement (intérêts nets) en 2026 ? »</li>
<li>« Liste-moi les investissements en retard ou en procédure. »</li>
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
</ul>
<p style={{ marginBottom: 0 }}>
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
(voir les FAQ de configuration ci-dessus) — les six premiers outils sont identiques dans les deux environnements.
</p>
</FaqItem>
<FaqItem question="Comment interroger mon portefeuille depuis Excel avec Power Query ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Comme le serveur MCP, Power Query s'appuie sur l'API v1 (lecture seule) via une clé API.
Power Query est intégré nativement à Excel (365 / 2016 et plus, Windows ou Mac) — rien à
installer. Chaque étape ci-dessous correspond à un écran que vous pouvez capturer pour
illustrer votre propre guide.
</p>
<div>
<DownloadLink href="/powerquery/crowdlending-powerquery-exemple.xlsb">
Télécharger le classeur complet (.xlsb)
</DownloadLink>
</div>
<p style={{ marginTop: 0, marginBottom: 16 }}>
Le classeur ci-dessus contient déjà les 7 requêtes ci-dessous, prêtes à charger — il ne
reste qu'à renseigner votre clé (étape 1). Vous pouvez aussi tout reconstruire à la main
avec les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> individuels
proposés à chaque étape ci-dessous.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 1 Générer une clé API</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>Mon compte Clés API Nouvelle clé</strong>.</li>
<li>Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé
affichée elle ne sera plus visible en clair ensuite.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 2 Créer les deux requêtes de connexion</h4>
<p>
Dans Excel : <strong style={{ color: 'var(--text)' }}>Données Obtenir des données À partir d'autres
sources → Requête vide</strong>. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query.
</p>
<ol style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Renommez-la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> (double-clic
sur son nom dans le volet Requêtes), puis <strong style={{ color: 'var(--text)' }}>Affichage Éditeur avancé</strong>,
effacez le contenu par défaut et collez :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"http://localhost:4000/api/v1"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiBaseUrl.pq">Télécharger ApiBaseUrl.pq</DownloadLink>
<img
src="/powerquery/apibaseurl-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<ol start={2} style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Cliquez <strong style={{ color: 'var(--text)' }}>Terminé</strong>, puis <strong style={{ color: 'var(--text)' }}>Accueil Fermer et charger dans Uniquement créer la connexion</strong> (pas besoin d'un tableau pour celle-ci).</li>
<li>Répétez l'opération pour une seconde requête vide nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code> :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"clk_live_VOTRE_CLE_API"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiKey.pq">Télécharger ApiKey.pq</DownloadLink>
<img
src="/powerquery/apikey-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé → Uniquement créer la connexion.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 3 — La fonction technique fnApiGet</h4>
<p>
Une troisième requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnApiGet</code>,
fait l'appel HTTP et transmet la clé dans l'en-tête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>X-API-Key</code> —
les requêtes suivantes la réutiliseront par son nom, sans la récrire :
</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat`}
</pre>
<DownloadLink href="/powerquery/queries/fnApiGet.pq">Télécharger fnApiGet.pq</DownloadLink>
<img
src="/powerquery/fnapiget-editeur-avance.png"
alt="Éditeur avancé Power Query — fonction fnApiGet"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 4 Première requête de données : Investissements</h4>
<p>Une nouvelle requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investissements</code> :</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat`}
</pre>
<DownloadLink href="/powerquery/queries/Investissements.pq">Télécharger Investissements.pq</DownloadLink>
<img
src="/powerquery/investissements-editeur-avance.png"
alt="Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>
Cette fois-ci, <strong style={{ color: 'var(--text)' }}>Terminé Fermer et charger</strong> (le bouton
simple, pas « ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos
investissements en ligne et en colonnes.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 5 — Actualiser</h4>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>Données → Actualiser tout</strong> (ou Ctrl+Alt+F5) à tout moment
pour recharger les données depuis l'API utile après un nouvel investissement ou remboursement
saisi dans l'app.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Aller plus loin — les autres requêtes</h4>
<p>Même principe (requête vide → nom exact → éditeur avancé → coller → charger) pour :</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil investisseur (ou liste des membres si clé « Famille et entreprises »).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Investisseur.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Dashboard.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements, filtrable par période.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Remboursements.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>DépôtsRetraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/DepotsRetraits.pq">.pq</DownloadLink>
</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnDetailInvestissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
Fonction avancée : sur la table <em>Investissements</em>, colonne personnalisée{' '}
<code style={{ fontFamily: 'monospace' }}>= fnDetailInvestissement([id])</code>{' '}
pour ramener le détail + les remboursements de chaque prêt.
</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/fnDetailInvestissement.pq">.pq</DownloadLink>
</td>
</tr>
</tbody>
</table>
<p>
Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche —
les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> ci-dessus
ne sont utiles que si vous préférez les coller vous-même dans un classeur existant.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Dépannage</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>« Impossible de se connecter au service distant »</strong> — le
backend n'est pas démarré, ou <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> est
incorrecte (vérifiez le port et le suffixe <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>/api/v1</code>).</li>
<li><strong style={{ color: 'var(--text)' }}>Erreur 401 / clé invalide</strong> régénérez une clé dans
Mon compte Clés API et mettez à jour la requête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code>.</li>
<li><strong style={{ color: 'var(--text)' }}>Avertissement de confidentialité au premier chargement</strong>
normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public »
selon votre contexte.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Comme le serveur MCP, ces requêtes sont strictement en lecture seule : aucune saisie n'est possible
depuis Excel, toute modification reste à faire dans l'application.
</p>
</FaqItem>
</div> </div>
)} )}
+230 -55
View File
@@ -8,6 +8,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx'; import Modal from '../components/Modal.jsx';
import { api } from '../api.js'; import { api } from '../api.js';
import { memberLabel } from '../utils/format.js'; import { memberLabel } from '../utils/format.js';
import { withDevOverrides } from '../utils/devOverrides.js';
/* ── Icônes nav ─────────────────────────────────────────────── */ /* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() { function IconUser() {
@@ -805,6 +806,12 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
// La clé "Famille et entreprises" (scope_all) n'est autorisée que depuis le
// profil principal — cohérent avec l'enforcement côté serveur (routes/apiKeys.js).
// Si aucun principal n'est trouvé (ne devrait pas arriver), on masque
// simplement l'option plutôt que de proposer un choix qui échouera au submit.
const principal = investisseurs.find(i => i.is_principal);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setNom(''); setNom('');
@@ -813,6 +820,8 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
} }
}, [open, investisseurs]); }, [open, investisseurs]);
const isScopeAll = investisseurId === 'all';
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!nom.trim()) return setErr('Le nom de la clé est requis'); if (!nom.trim()) return setErr('Le nom de la clé est requis');
@@ -820,7 +829,10 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
const created = await api.post('/api-keys', { nom: nom.trim(), investisseur_id: Number(investisseurId) }); const payload = isScopeAll
? { nom: nom.trim(), investisseur_id: principal.id, scope_all: true }
: { nom: nom.trim(), investisseur_id: Number(investisseurId) };
const created = await api.post('/api-keys', payload);
onCreated(created); onCreated(created);
} catch (e) { setErr(e.message); } } catch (e) { setErr(e.message); }
finally { setBusy(false); } finally { setBusy(false); }
@@ -841,10 +853,13 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
{investisseurs.map(i => ( {investisseurs.map(i => (
<option key={i.id} value={i.id}>{memberLabel(i)}</option> <option key={i.id} value={i.id}>{memberLabel(i)}</option>
))} ))}
{principal && <option value="all">Famille et entreprises</option>}
</select> </select>
</div> </div>
<p className="text-muted" style={{ margin: 0, fontSize: 'var(--fs-sm)' }}> <p className="text-muted" style={{ margin: 0, fontSize: 'var(--fs-sm)' }}>
La clé donne un accès en lecture seule aux données de cet investisseur. Elle ne sera affichée en clair qu'une seule fois. {isScopeAll
? "La clé donnera un accès en lecture seule agrégé à tous les membres du foyer (comme la vue « Famille et entreprises » de l'app). Elle ne sera affichée en clair qu'une seule fois."
: "La clé donne un accès en lecture seule aux données de cet investisseur. Elle ne sera affichée en clair qu'une seule fois."}
</p> </p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="ghost" onClick={onClose}>Annuler</button> <button type="button" className="ghost" onClick={onClose}>Annuler</button>
@@ -1043,7 +1058,7 @@ function ApiKeysSection() {
{keys.map(k => ( {keys.map(k => (
<tr key={k.id}> <tr key={k.id}>
<td>{k.nom}</td> <td>{k.nom}</td>
<td>{k.investisseur_nom}</td> <td>{k.scope_all ? 'Famille et entreprises' : k.investisseur_nom}</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{k.key_prefix}</td> <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{k.key_prefix}</td>
<td>{fmtKeyDate(k.created_at)}</td> <td>{fmtKeyDate(k.created_at)}</td>
<td>{fmtKeyDate(k.last_used_at)}</td> <td>{fmtKeyDate(k.last_used_at)}</td>
@@ -1091,27 +1106,30 @@ function ApiKeysSection() {
} }
/* ── Serveur MCP ────────────────────────────────────────────── /* ── Serveur MCP ──────────────────────────────────────────────
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP local Guide pas-à-pas pour connecter Claude Desktop au serveur MCP
(mcp-server/ à la racine du projet). Le JSON de config est généré (mcp-server/ à la racine du projet, un seul modèle url+headers en HTTP,
côté client à partir des champs ci-dessous ; la clé API saisie ici que ce soit en développement local ou déployé à distance en prod — voir
reste uniquement en mémoire du navigateur, elle n'est jamais envoyée mcp-server/README.md). Le JSON de config est généré côté client à partir
au backend — seulement utilisée pour composer l'aperçu à copier. ── */ des champs ci-dessous ; la clé API saisie ici reste uniquement en mémoire
du navigateur, elle n'est jamais envoyée au backend — seulement utilisée
pour composer l'aperçu à copier. ── */
/** Devine une URL d'API raisonnable selon l'environnement courant : /** Devine une URL de serveur MCP raisonnable selon l'environnement courant :
* en dev (Vite sur :5173), le process Node du serveur MCP ne passe pas * en dev (Vite sur :5173 ou localhost), le serveur MCP tourne en local sur
* par le proxy Vite, il faut donc viser directement le port du backend (4000). * son port par défaut (npm run dev, :4100). En prod, il s'agit du
* En prod (nginx sert front + /api sur la même origine), l'origine courante convient. */ * sous-domaine dédié mcp.<domaine de l'app>, déjà déployé en continu. */
function guessMcpApiUrl() { function guessMcpUrl() {
if (typeof window === 'undefined') return 'http://localhost:4000/api/v1'; if (typeof window === 'undefined') return 'http://localhost:4100/mcp';
const { hostname, port, origin } = window.location; const { hostname, port } = window.location;
if (port === '5173') return `http://${hostname}:4000/api/v1`; if (port === '5173' || hostname === 'localhost' || hostname === '127.0.0.1') {
return `${origin}/api/v1`; return 'http://localhost:4100/mcp';
}
return `https://mcp.${hostname}/mcp`;
} }
/** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de /** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de
* l'URL de l'API : localhost/IP locale ou nom d'hôte contenant "dev" → * l'URL du serveur MCP : localhost/IP locale → dev, tout le reste → prod.
* dev, tout le reste → prod. Best-effort — reste modifiable manuellement * Best-effort — reste modifiable manuellement pour les cas particuliers. */
* pour les cas particuliers (domaine de test qui ne contient pas "dev"...). */
function detectLabelFromUrl(url) { function detectLabelFromUrl(url) {
if (!url) return 'prod'; if (!url) return 'prod';
let hostname; let hostname;
@@ -1146,28 +1164,65 @@ function CopyBlock({ text }) {
} }
function McpServerSection({ goToApiKeys }) { function McpServerSection({ goToApiKeys }) {
const [mcpPath, setMcpPath] = useState('C:\\dev\\crowdlending-app\\mcp-server\\index.js'); const navigate = useNavigate();
const [apiUrl, setApiUrl] = useState(guessMcpApiUrl()); const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis apiUrl, sinon override manuel const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
const [debugFlag, setDebugFlag] = useState(false); // ajoute --debug : génère un fichier mcp-server-<nom>.log dédié (voir Dépannage)
const [systemCaFlag, setSystemCaFlag] = useState(true); // ajoute NODE_OPTIONS=--use-system-ca : contourne un antivirus/proxy qui intercepte le HTTPS (voir Dépannage) — coché par défaut, ne s'applique qu'en prod (voir envBlock)
const mcpUrlEditedRef = useRef(false); // true dès que l'utilisateur touche le champ — n'écrase plus la valeur saisie
const detectedLabel = detectLabelFromUrl(apiUrl); // Pré-remplit avec l'URL renseignée par l'admin (Administration → Général)
// si elle existe, plutôt que de laisser la seule devinette basée sur
// l'hôte courant (utile notamment quand l'app est servie derrière un nom
// de domaine différent du sous-domaine mcp.<hostname> par défaut).
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
// En dev local, la base est régulièrement une copie de la prod : la
// valeur stockée pointerait encore vers mcp.<domaine de prod> tant
// qu'on ne la corrige pas à la main — voir utils/devOverrides.js.
const info = withDevOverrides(d);
if (info.mcpUrl && !mcpUrlEditedRef.current) setMcpUrl(info.mcpUrl);
}).catch(() => {});
}, []);
const detectedLabel = detectLabelFromUrl(mcpUrl);
const label = manualLabel ?? detectedLabel; const label = manualLabel ?? detectedLabel;
const isLocal = label === 'dev';
const serverKey = `crowdlending-${label}`; const serverKey = `crowdlending-${label}`;
const env = { // claude_desktop_config.json n'a pas de champ url/headers natif : on passe
CROWDLENDING_API_KEY: apiKey || '<VOTRE_CLE_API>', // par mcp-remote (https://github.com/geelen/mcp-remote), un pont stdio↔HTTP
CROWDLENDING_API_URL: apiUrl, // que Claude Desktop lance comme n'importe quel serveur "command". La clé
CROWDLENDING_LABEL: label, // API passe en variable d'environnement plutôt que directement dans args
// (bug connu de Claude Desktop Windows qui tronque les valeurs à espaces).
const envVarName = `${label.toUpperCase()}_API_KEY`;
const mcpRemoteArgs = [
'-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`,
...(debugFlag ? ['--debug'] : []),
];
// Sur Windows, npx est en réalité npx.cmd (un script) : child_process.spawn,
// utilisé par Claude Desktop, ne sait pas l'exécuter directement sans passer
// par l'interpréteur de commandes — le serveur reste bloqué sur "running"
// sans jamais répondre. Il faut donc l'appeler via cmd /c. Sans risque sur
// macOS/Linux, qui n'ont pas ce problème (npx s'exécute nativement).
const isWindows = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '');
const envBlock = {
[envVarName]: apiKey || '<VOTRE_CLE_API>',
// Uniquement pertinent pour une URL distante (prod) : force Node à utiliser
// le magasin de certificats du système plutôt que le sien, pour accepter
// les connexions re-signées par un antivirus/proxy à inspection HTTPS
// (Avast, Kaspersky, proxy d'entreprise…) — voir Dépannage.
...(!isLocal && systemCaFlag ? { NODE_OPTIONS: '--use-system-ca' } : {}),
}; };
const configJson = JSON.stringify({ const configJson = JSON.stringify({
mcpServers: { mcpServers: {
[serverKey]: { [serverKey]: isWindows
command: 'node', ? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: envBlock }
args: [mcpPath], : { command: 'npx', args: mcpRemoteArgs, env: envBlock },
env,
},
}, },
}, null, 2); }, null, 2);
@@ -1176,8 +1231,8 @@ function McpServerSection({ goToApiKeys }) {
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3> <h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}> <p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule. Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule.
Le serveur (dossier <code>mcp-server/</code> du projet) doit être installé sur cette machine Un seul modèle de connexion, en développement local comme en production : une URL de serveur MCP
(<code>npm install</code>) voir <code>mcp-server/README.md</code> pour le détail. et votre clé API personnelle voir <code>mcp-server/README.md</code> pour le détail.
</p> </p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
@@ -1194,12 +1249,8 @@ function McpServerSection({ goToApiKeys }) {
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4> <h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
<div> <div>
<label>Chemin vers mcp-server/index.js</label> <label>URL du serveur MCP</label>
<input value={mcpPath} onChange={e => setMcpPath(e.target.value)} /> <input value={mcpUrl} onChange={e => { mcpUrlEditedRef.current = true; setMcpUrl(e.target.value); }} />
</div>
<div>
<label>URL de l'API</label>
<input value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
</div> </div>
<div> <div>
<label>Clé API</label> <label>Clé API</label>
@@ -1218,7 +1269,7 @@ function McpServerSection({ goToApiKeys }) {
</span> </span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{manualLabel === null {manualLabel === null
? <>déduit de l'URL de l'API ci-dessus (localhost / « dev » → dev, sinon prod)</> ? <>déduit de l'URL ci-dessus (localhost → dev, sinon prod)</>
: <>forcé manuellement</>} : <>forcé manuellement</>}
</span> </span>
</div> </div>
@@ -1236,29 +1287,79 @@ function McpServerSection({ goToApiKeys }) {
)} )}
</div> </div>
</div> </div>
{isLocal ? (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <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 En développement, ce serveur doit tourner sur votre machine pour que l'URL ci-dessus réponde :
prod accessibles simultanément, répétez ces étapes une deuxième fois avec une URL d'API pointant <code style={{ display: 'block', margin: '6px 0', padding: '6px 8px', borderRadius: 6, background: 'var(--surface-2, #f9fafb)' }}>
vers l'autre environnement (et une clé API distincte) — l'étiquette et la clé de config cd mcp-server &amp;&amp; npm install &amp;&amp; npm run dev
(<code>{serverKey}</code> ci-dessous) s'ajustent automatiquement. Elle apparaît dans le titre et </code>
la description de chaque outil, pour que l'agent ne confonde jamais les deux portefeuilles. comme pour le backend et le frontend. Laissez ce terminal ouvert tant que vous utilisez Claude Desktop.
</p> </p>
) : (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
En production, le serveur tourne déjà en continu (service <code>crowdlending-mcp</code>) aucune
action nécessaire au-delà de renseigner l'URL et votre clé API.
</p>
)}
{isLocal && (
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir aussi votre
portefeuille en production accessible depuis Claude Desktop, répétez ces étapes une deuxième fois
avec l'URL de production (et une clé API distincte) la clé de config
(<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais
les deux connexions.
</p>
)}
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <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>. 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 l'installation (même téléchargée directement depuis anthropic.com l'origine ne garantit
selon que Claude Desktop vient de claude.ai (<code>%APPDATA%\Claude\claude_desktop_config.json</code>) rien), Claude Desktop peut être packagé en MSIX et virtualiser ce fichier : le bouton ouvre parfois
ou du Microsoft Store (dossier virtualisé sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\</code>). une copie sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\claude_desktop_config.json</code>
Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement l'entrée alors que l'app tourne réellement avec <code>%APPDATA%\Claude\claude_desktop_config.json</code> (ou
<code>"{serverKey}"</code> ci-dessous sans écraser le reste ; sinon collez le bloc entier. l'inverse). Si vos outils <code>crowdlending_*</code> n'apparaissent jamais après configuration,
vérifiez les <strong>deux emplacements</strong> et éditez celui qui correspond au dossier
<code>logs\mcp.log</code> se met réellement à jour quand vous relancez l'app (voir Dépannage
ci-dessous). Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement
l'entrée <code>"{serverKey}"</code> sans écraser le reste ; sinon collez le bloc entier.
</p> </p>
{isWindows && (
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
La config ci-dessous passe par <code>cmd /c npx</code> plutôt que <code>npx</code> directement :
nécessaire sous Windows, Claude Desktop ne sait pas lancer <code>npx</code> (script
<code>.cmd</code>) sans passer par l'interpréteur de commandes — sinon le serveur reste bloqué
sur « running » sans jamais répondre.
</p>
)}
<img <img
src="/mcp/claude-desktop-developer-settings.png" src="/mcp/claude-desktop-developer-settings.png"
alt="Claude Desktop — Réglages → Développeur → Serveurs MCP locaux → Modifier la config" 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' }} 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'; }} onError={(e) => { e.currentTarget.style.display = 'none'; }}
/> />
<div style={{ margin: '0 0 10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={debugFlag} onChange={e => setDebugFlag(e.target.checked)} style={{ width: 'auto' }} />
Ajouter <code>--debug</code>
</label>
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Recommandé en cas de problème — voir Dépannage ci-dessous.
</p>
</div>
{!isLocal && (
<div style={{ margin: '0 0 10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={systemCaFlag} onChange={e => setSystemCaFlag(e.target.checked)} style={{ width: 'auto' }} />
Ajouter <code>NODE_OPTIONS=--use-system-ca</code>
</label>
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Coché par défaut : nécessaire si un antivirus ou un proxy intercepte le HTTPS — voir Dépannage
ci-dessous. Décochez uniquement si vous savez que ce n'est pas votre cas.
</p>
</div>
)}
<CopyBlock text={configJson} /> <CopyBlock text={configJson} />
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4> <h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
@@ -1267,10 +1368,84 @@ function McpServerSection({ goToApiKeys }) {
</p> </p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4> <h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}> <p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans la liste des outils MCP de Claude Desktop, les 7 outils <code>crowdlending_*</code> doivent Demandez à Claude : « <strong style={{ color: 'var(--text)' }}>Peux-tu me lister les outils auxquels
apparaître. Testez avec une question du type « Quel est mon encours de crowdlending actuellement ? ». tu as accès ?</strong> » — c'est la façon la plus fiable de vérifier que la connexion fonctionne
vraiment (plutôt qu'une question sur vos données, qui peut échouer pour d'autres raisons même si la
connexion est bonne). Les outils <code>crowdlending_*</code> doivent apparaître dans sa réponse
{isLocal
? <> (6, ou 7 si <code>crowdlending_fetch_url</code> est activé voir <code>mcp-server/README.md</code>)</>
: ' (6 au total)'}.
</p> </p>
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Une fois la connexion confirmée, consultez la FAQ « Comment utiliser le serveur MCP » pour des
exemples de questions et le détail de chaque fonction.
</p>
<button type="button" className="ghost" onClick={() => navigate('/aide?section=faq')} style={{ marginTop: -12, marginBottom: 20 }}>
Voir la FAQ
</button>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>6. Dépannage</h4>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Le serveur reste sur « running » indéfiniment, aucun outil n'apparaît, aucune erreur visible
</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Cause la plus fréquente sous Windows, même avec <code>cmd /c</code> déjà en place : <code>npx</code>
recontacte le registre npm (<code>registry.npmjs.org</code>) à chaque lancement pour vérifier la
version, et un antivirus ou un proxy avec inspection HTTPS (Avast, Kaspersky, ESET, proxy
d'entreprise) fait échouer cette requête avec une erreur de certificat invisible depuis Claude
Desktop, qui attend simplement une réponse jamais reçue jusqu'à expirer au bout d'une minute.
Confirmez en cherchant <code>UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> dans les logs (voir plus bas).
Solution : installez <code>mcp-remote</code> une bonne fois pour toutes (<code>npm install -g
mcp-remote</code>) puis redémarrez Claude Desktop <code>npx</code> utilisera alors le binaire déjà
installé sans repasser par le registre à chaque fois.
</p>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}> trouver les logs</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
<code>logs\mcp.log</code> trace les échanges entre Claude Desktop et le process local (tous serveurs
confondus) ; <code>logs\mcp-server-{serverKey}.log</code> contient la sortie détaillée de ce serveur
précis, uniquement si <code>--debug</code> est activé ci-dessus (case à cocher, étape 3). Le bouton
« Afficher les journaux » de Claude Desktop peut ne pas s'ouvrir sous Windows (bug connu) — allez
chercher directement dans le dossier <code>logs</code>, à l'un des deux emplacements mentionnés à
l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).
</p>
{!isLocal && (
<>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Connexion refusée avec une erreur de certificat, alors que le serveur répond bien
</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Symptôme différent du précédent : la config est correcte, mais <code>mcp-remote</code> échoue à
joindre l'URL de production elle-même (pas le registre npm cette fois) avec une erreur
<code> UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> dans les logs. Même cause de fond un antivirus ou
un proxy avec inspection HTTPS (Avast, Kaspersky, ESET) re-signe le trafic avec son propre
certificat, que Node.js ne reconnaît pas (contrairement à votre navigateur, qui lui fait
confiance via le magasin Windows). Solution : cochez la case <code>NODE_OPTIONS=--use-system-ca</code>
ci-dessus (étape 3) Node utilisera alors le magasin de certificats Windows plutôt que le sien.
Nécessite Node.js 22.16 ou plus récent (vérifiable avec <code>node -v</code>) ; sur une version
plus ancienne, il faut exporter le certificat racine de l'antivirus (<code>certmgr.msc</code> →
Autorités de certification racines de confiance → Exporter en Base-64 X.509) et le référencer via
<code> NODE_EXTRA_CA_CERTS</code> à la place. Évitez de désactiver complètement la vérification
TLS (<code>NODE_TLS_REJECT_UNAUTHORIZED=0</code>) : cette connexion sort sur internet avec votre
clé API et vos données, contrairement au développement local en <code>localhost</code>.
</p>
</>
)}
{isLocal && (
<>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Vérifier côté serveur</p>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
La console du serveur (le terminal où tourne <code>npm run dev</code>) affiche désormais une ligne
par requête reçue — session, outil appelé, statut, durée. Si rien n'y apparaît alors qu'un appel a
été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
<code>npx</code>/<code>mcp-remote</code> (voir ci-dessus), pas dans <code>server.js</code>.
</p>
</>
)}
</div> </div>
); );
} }
+29 -7
View File
@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../../api.js'; import { api } from '../../api.js';
import { withDevOverrides } from '../../utils/devOverrides.js';
function SettingRow({ label, description, children }) { function SettingRow({ label, description, children }) {
return ( return (
@@ -34,6 +35,7 @@ function SectionHeader({ title, description }) {
const DEFAULT = { const DEFAULT = {
appName: 'Crowdlending Tracker', appName: 'Crowdlending Tracker',
appUrl: '', appUrl: '',
mcpUrl: '',
allowRegistration: true, allowRegistration: true,
minPasswordLength: 8, minPasswordLength: 8,
}; };
@@ -46,12 +48,19 @@ export default function GeneralSection() {
useEffect(() => { useEffect(() => {
api.get('/admin/general') api.get('/admin/general')
.then(d => setForm({ .then(d => {
appName: d.appName || 'Crowdlending Tracker', // En dev local, la base est régulièrement une copie de la prod :
appUrl: d.appUrl || '', // appUrl/mcpUrl y pointeraient encore vers l'environnement de prod
allowRegistration: d.allowRegistration !== false, // tant qu'on ne les corrige pas — voir utils/devOverrides.js.
minPasswordLength: d.minPasswordLength || 8, const dd = withDevOverrides(d);
})) setForm({
appName: dd.appName || 'Crowdlending Tracker',
appUrl: dd.appUrl || '',
mcpUrl: dd.mcpUrl || '',
allowRegistration: dd.allowRegistration !== false,
minPasswordLength: dd.minPasswordLength || 8,
});
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -64,6 +73,7 @@ export default function GeneralSection() {
await api.patch('/admin/general', { await api.patch('/admin/general', {
appName: form.appName.trim(), appName: form.appName.trim(),
appUrl: form.appUrl.trim(), appUrl: form.appUrl.trim(),
mcpUrl: form.mcpUrl.trim(),
allowRegistration: form.allowRegistration, allowRegistration: form.allowRegistration,
minPasswordLength: form.minPasswordLength, minPasswordLength: form.minPasswordLength,
}); });
@@ -103,7 +113,7 @@ export default function GeneralSection() {
/> />
</SettingRow> </SettingRow>
<SettingRow label="URL de la plateforme" description="Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://)."> <SettingRow label="URL de la plateforme" description={`Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input <input
className="form-input" className="form-input"
type="url" type="url"
@@ -114,6 +124,18 @@ export default function GeneralSection() {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</SettingRow> </SettingRow>
<SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
maxLength={500}
value={form.mcpUrl}
placeholder="https://mcp.mon-app.example.com/mcp"
onChange={e => set('mcpUrl', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
</div> </div>
{/* Accès */} {/* Accès */}
+27
View File
@@ -0,0 +1,27 @@
/**
* devOverrides.js — Neutralise les paramètres généraux venant de la base en
* développement local.
*
* Contexte : la base SQLite locale est régulièrement remplacée par une copie
* de la base de production (Admin → Export complet, puis restauration en
* local) — ce qui inclut les paramètres généraux (URL de la plateforme, URL
* du serveur MCP). Une fois rejouée en local, cette copie pointe encore vers
* l'environnement de prod tant qu'elle n'a pas été corrigée à la main.
*
* `import.meta.env.DEV` est une constante figée par Vite AU MOMENT DU BUILD :
* `true` uniquement quand le code tourne via `vite` (npm run dev), toujours
* `false` dans un build de production (`vite build`), quel que soit le
* contenu de la base utilisée à l'exécution. Aucun effet possible en
* production, donc.
*/
export const DEV_APP_URL = 'http://localhost:5173';
export const DEV_MCP_URL = 'http://localhost:4100/mcp';
/** Retourne `info` (forme de /api/app-info ou /api/admin/general) avec
* `appUrl`/`mcpUrl` forcés aux valeurs locales en développement. Inchangé
* tel quel en production. */
export function withDevOverrides(info) {
if (!import.meta.env.DEV) return info;
return { ...info, appUrl: DEV_APP_URL, mcpUrl: DEV_MCP_URL };
}
-1
View File
@@ -2,4 +2,3 @@ node_modules
npm-debug.log npm-debug.log
*.log *.log
README.md README.md
index.js
+2 -2
View File
@@ -5,11 +5,11 @@ ENV NODE_ENV=production
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY tools.js http-server.js ./ COPY tools.js server.js ./
EXPOSE 4100 EXPOSE 4100
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "http-server.js"] CMD ["node", "server.js"]
+166 -153
View File
@@ -1,28 +1,20 @@
# crowdlending-mcp-server # crowdlending-mcp-server
Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
les données d'un investisseur (investissements, remboursements, crowdlending. Il expose en lecture seule les données d'un investisseur
dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code, (investissements, remboursements, dépôts/retraits, dashboard) à un client
ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1` MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
du backend. s'appuyant sur l'API publique `/api/v1` du backend.
Il ne fait aucune écriture : toutes les modifications restent à faire dans Il ne fait aucune écriture : toutes les modifications restent à faire dans
l'app web. l'app web.
Deux modes, deux fichiers : **Un seul serveur, un seul fichier (`server.js`)**, utilisé aussi bien en
développement local (`npm run dev`, comme le backend et le frontend) qu'en
- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude production (service Docker `crowdlending-mcp`, derrière Traefik sur
Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en `mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les
local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web). deux — seule la configuration change (quelle API cibler, quels outils
- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par activer).
l'administrateur de l'instance) sur `mcp.crowdlending.croguennec.net`,
accessible à n'importe quel utilisateur distant sans rien installer :
juste une URL + sa clé API personnelle à coller dans son client MCP.
N'inclut PAS `crowdlending_fetch_url` (voir plus bas).
Les deux partagent le même code pour les 6 outils de lecture de données
(`tools.js`) — leur comportement ne peut donc pas diverger entre les deux
modes.
## Prérequis ## Prérequis
@@ -31,200 +23,204 @@ modes.
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre - Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
instance en production) instance en production)
## Installation ## Développement local
Comme pour `backend/` et `frontend/` :
``` ```
cd mcp-server cd mcp-server
npm install npm install
npm run dev
``` ```
## Configuration `npm run dev` (comme `backend`) relance automatiquement le serveur à chaque
modification de fichier (`node --watch`). Par défaut, il écoute sur
`http://localhost:4100` et cible l'API locale (`http://localhost:4000/api/v1`).
Trois variables d'environnement : Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
| Variable | Obligatoire | Défaut | Exemple |
|---|---|---|---|
| `CROWDLENDING_API_KEY` | oui | — | `clk_live_...` |
| `CROWDLENDING_API_URL` | non | `http://localhost:4000/api/v1` | `https://mon-domaine.fr/api/v1` |
| `CROWDLENDING_LABEL` | non | — | `dev`, `prod` |
`CROWDLENDING_LABEL` sert uniquement à distinguer plusieurs instances
connectées en même temps (voir [Faire tourner dev et prod en même
temps](#faire-tourner-dev-et-prod-en-même-temps)) : il apparaît dans le nom
du serveur, dans le titre de chaque outil (`[DEV]` / `[PROD]`) et dans la
description (avec l'URL API ciblée), pour que l'agent — et vous — sachiez
toujours quel environnement est interrogé.
## Utiliser avec Claude Desktop
Localisez le fichier de config Claude Desktop — le chemin diffère selon la
provenance de l'installation Windows :
- **Installeur classique** (téléchargé depuis claude.ai) :
`%APPDATA%\Claude\claude_desktop_config.json`
- **Microsoft Store** : Windows redirige `%APPDATA%` vers un dossier virtualisé
propre à l'app —
`%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json`
(le `<id>` est un identifiant généré, propre à votre installation).
Le plus fiable dans les deux cas : Réglages → Développeur → Serveurs MCP
locaux → **Modifier la config**, qui ouvre directement le bon fichier quelle
que soit la provenance de l'installation.
Ajoutez une entrée dans `mcpServers` :
```json ```json
// macOS / Linux
{ {
"mcpServers": { "mcpServers": {
"crowdlending": { "crowdlending-dev": {
"command": "node", "command": "npx",
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], "args": [
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": { "env": {
"CROWDLENDING_API_KEY": "clk_live_...", "CROWDLENDING_API_KEY": "clk_live_..."
"CROWDLENDING_API_URL": "http://localhost:4000/api/v1"
} }
} }
} }
} }
``` ```
Redémarrez Claude Desktop. L'icône 🔌 (ou le menu des outils MCP) doit ```json
afficher les 7 outils `crowdlending_*` ci-dessous. // Windows
{
"mcpServers": {
"crowdlending-dev": {
"command": "cmd",
"args": [
"/c", "npx",
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": {
"CROWDLENDING_API_KEY": "clk_live_..."
}
}
}
}
```
Pour pointer vers votre instance de production plutôt que le backend local, **Important :** `claude_desktop_config.json` n'accepte que des entrées
changez uniquement `CROWDLENDING_API_URL` (et utilisez une clé API générée `command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
sur cette instance). fichier (contrairement à d'autres clients MCP). Pour un serveur HTTP comme
celui-ci, on passe donc par
[`mcp-remote`](https://github.com/geelen/mcp-remote), un petit pont
stdio↔HTTP officiel : Claude Desktop lance `npx mcp-remote` comme d'habitude,
qui se charge de parler HTTP à notre serveur en coulisses, en-tête
`X-API-Key` inclus. Aucune installation manuelle requise, `npx` le télécharge
à la volée.
**Sous Windows, le wrapper `cmd /c` est obligatoire** (voir le second bloc
ci-dessus) : `npx` y est en réalité `npx.cmd` (un script), et la façon dont
Claude Desktop lance les process (`spawn` sans interpréteur de commandes) ne
sait pas l'exécuter directement — sans ce wrapper, le serveur reste affiché
comme « running » dans Claude Desktop mais ne répond jamais (blocage
silencieux, pas d'erreur explicite). C'est un problème Node.js/Windows
connu, pas spécifique à ce serveur — la config générée automatiquement dans
Mon compte → Serveur MCP l'applique déjà si elle détecte Windows.
Notez aussi l'absence d'espace autour du `:` dans `--header` : Claude Desktop
(Windows) a un bug connu qui tronque les arguments contenant un espace — on
passe donc la valeur réelle (avec l'espace éventuel) via une variable
d'environnement dans `env` plutôt que directement dans `args`.
Redémarrez Claude Desktop. Les outils `crowdlending_*` doivent apparaître
(voir la liste plus bas — `crowdlending_fetch_url` en plus si activé, voir
Configuration).
## Configuration
Variables d'environnement, toutes optionnelles sauf pour un déploiement
distant réel (en local, les valeurs par défaut conviennent) :
| Variable | Défaut | Description |
|---|---|---|
| `PORT` | `4100` | Port d'écoute HTTP |
| `CROWDLENDING_API_URL` | `http://localhost:4000/api/v1` | API v1 ciblée |
| `MCP_LABEL` | — | Étiquette d'environnement (`dev`, `prod`...) — voir plus bas |
| `MCP_ENABLE_FETCH_URL` | `false` | Active l'outil `crowdlending_fetch_url` (voir plus bas) |
| `MCP_ALLOWED_HOSTS` | `mcp.crowdlending.croguennec.net,localhost` | En-têtes `Host` acceptés (protection anti DNS-rebinding) |
Aucune clé API fixe n'est configurée côté serveur, contrairement à une
ancienne version qui utilisait le transport stdio : chaque session MCP lit
sa propre clé dans l'en-tête `X-API-Key` de la requête qui l'initialise
(transmis via `mcp-remote --header`, voir ci-dessus). Un même process peut
donc servir plusieurs utilisateurs/sessions en parallèle sans jamais mélanger
leurs données — voir le déploiement distant plus bas.
## Faire tourner dev et prod en même temps ## Faire tourner dev et prod en même temps
Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il 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`. suffit de déclarer deux entrées dans `mcpServers`, une par URL. Chaque outil
Chaque outil est alors automatiquement rattaché à son serveur d'origine — est automatiquement rattaché à son serveur d'origine — pas de collision
pas de collision technique possible entre les deux, même si les noms possible, même si les noms d'outils sont identiques des deux côtés.
d'outils (`crowdlending_get_dashboard`, etc.) sont identiques des deux côtés.
```json ```json
{ {
"mcpServers": { "mcpServers": {
"crowdlending-dev": { "crowdlending-dev": {
"command": "node", "command": "npx",
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], "args": ["-y", "mcp-remote", "http://localhost:4100/mcp", "--header", "X-API-Key:${DEV_API_KEY}"],
"env": { "env": { "DEV_API_KEY": "clk_live_..." }
"CROWDLENDING_API_KEY": "clk_live_...",
"CROWDLENDING_API_URL": "http://localhost:4000/api/v1",
"CROWDLENDING_LABEL": "dev"
}
}, },
"crowdlending-prod": { "crowdlending-prod": {
"command": "node", "command": "npx",
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"], "args": ["-y", "mcp-remote", "https://mcp.crowdlending.croguennec.net/mcp", "--header", "X-API-Key:${PROD_API_KEY}"],
"env": { "env": { "PROD_API_KEY": "clk_live_..." }
"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 Sous Windows, remplacez `"command": "npx"` par `"command": "cmd", "args": ["/c", "npx", ...]`
sur chaque backend) : ça permet de révoquer l'une sans affecter l'autre, et sur chacune des deux entrées (voir Développement local plus haut).
c'est cohérent avec le principe d'une clé par usage.
Avec `CROWDLENDING_LABEL` renseigné, chaque outil affiche son environnement Utilisez deux clés API différentes (une par instance) : ça permet de
dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description révoquer l'une sans affecter l'autre. Réglez `MCP_LABEL=dev` (côté serveur
se termine par la source exacte interrogée — de quoi lever toute ambiguïté local, dans votre `.env` ou variable d'environnement au lancement) pour que
si vous demandez « mon encours en prod » vs « mon encours en dev ». le titre de chaque outil affiche `[DEV]` et lève toute ambiguïté — le
serveur distant est déjà configuré avec `MCP_LABEL` correspondant en
production.
## Serveur distant — pour les utilisateurs sans installation locale ## Déploiement en production (Docker)
Si vous n'êtes pas sur la machine qui héberge ce dépôt (pas de Node.js, pas Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit
envie de cloner le repo), vous pouvez vous connecter directement au serveur et lance ce même `server.js`, exposé via Traefik sur
MCP distant déployé sur `https://mcp.crowdlending.croguennec.net` — aucune `mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement
installation nécessaire, juste votre clé API personnelle. au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` :
il est volontairement accessible depuis internet, pour des utilisateurs
distants qui ne peuvent pas faire tourner le serveur en local — la clé API
est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et
révoquez-la immédiatement en cas de doute (Mon compte → Clés API).
**1. Créer votre clé API** — dans l'app, Mon compte → Clés API → Nouvelle Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
clé (elle ne sera affichée qu'une seule fois, copiez-la).
**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur →
Serveurs MCP locaux → Modifier la config, puis ajoutez :
```json
{
"mcpServers": {
"crowdlending-distant": {
"url": "https://mcp.crowdlending.croguennec.net/mcp",
"headers": {
"X-API-Key": "clk_live_..."
}
}
}
}
```
**3. Redémarrez Claude Desktop.** Les 6 outils `crowdlending_*` doivent
apparaître (sans `crowdlending_fetch_url`, réservé au serveur local).
### Différences avec le serveur local
- **Pas de `crowdlending_fetch_url`** : lire une page web arbitraire pour un
utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice
réel pour ce cas d'usage. Cet outil reste réservé au serveur local.
- **Authentification par requête, pas par process** : le serveur local a une
clé API fixée une fois pour toutes via une variable d'environnement — un
process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en
parallèle : chaque session est créée à partir de la clé API envoyée dans
l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette
session uniquement. Deux utilisateurs ne partagent jamais de données.
- **Exposé publiquement, sans la liste blanche d'IP** qui protège le reste de
l'app (`ipwhitelist-all`) : la clé API est la seule barrière d'accès.
Traitez-la comme un mot de passe — ne la partagez pas, révoquez-la
immédiatement en cas de doute (Mon compte → Clés API).
- **Limite de débit** : 60 requêtes/minute par adresse IP, au-delà l'API
répond `429`.
- **Sessions** : une session inactive plus de 30 minutes est fermée côté
serveur (mémoire uniquement, aucune persistance) ; votre client MCP en
recréera une automatiquement à la prochaine requête.
### Déploiement (administrateur de l'instance)
Le service `crowdlending-mcp` du `docker-compose.yml` construit et lance
`http-server.js`, exposé via Traefik sur `mcp.crowdlending.croguennec.net`
(certificat TLS automatique, même resolver que le reste de l'app). Un
enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement. `crowdlending.croguennec.net`, est nécessaire avant le premier déploiement.
Variables d'environnement du service : `CROWDLENDING_API_URL` (URL interne
du backend sur le réseau Docker, déjà configurée) et `MCP_ALLOWED_HOSTS` `MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml)
(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit : cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour
correspondre exactement au(x) nom(s) de domaine exposé(s)). un usage perso où vous seul détenez la clé, mais risqué (SSRF) face à des
utilisateurs distants non maîtrisés.
Autres protections : limite de 60 requêtes/minute par IP (au-delà, `429`),
et fermeture automatique des sessions inactives depuis plus de 30 minutes
(mémoire uniquement, aucune persistance).
## Tester sans Claude Desktop ## Tester sans Claude Desktop
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
de lister et appeler les outils depuis une interface web, sans configurer de de lister et appeler les outils depuis une interface web :
client :
``` ```
CROWDLENDING_API_KEY=clk_live_... npm run inspect npm run inspect
``` ```
Dans l'interface, choisissez le transport **Streamable HTTP**, entrez
l'URL (`http://localhost:4100/mcp` en dev) et ajoutez l'en-tête
`X-API-Key` avec votre clé.
## Outils exposés ## Outils exposés
Tous en lecture seule (`readOnlyHint: true`) : Tous en lecture seule (`readOnlyHint: true`) :
| Outil | Description | | Outil | Description |
|---|---| |---|---|
| `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé | | `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé (liste des membres si clé « Famille et entreprises ») |
| `crowdlending_get_dashboard` | Synthèse KPI : capital investi, capital en risque, intérêts perçus (filtrable par année), cash | | `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_list_investissements` | Liste des investissements, filtrable par statut |
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements | | `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période | | `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash | | `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
| `crowdlending_fetch_url` | *(serveur local uniquement)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous | | `crowdlending_fetch_url` | *(actif seulement si `MCP_ENABLE_FETCH_URL=true`)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous |
## Lire une annonce de projet (`crowdlending_fetch_url`) ## Lire une annonce de projet (`crowdlending_fetch_url`)
Désactivé par défaut — à activer avec `MCP_ENABLE_FETCH_URL=true`,
typiquement en développement local uniquement (voir Configuration et
Sécurité plus haut).
Cet outil récupère une page web et en extrait le contenu lisible (titre + Cet outil récupère une page web et en extrait le contenu lisible (titre +
texte principal) via [Readability](https://github.com/mozilla/readability), texte principal) via [Readability](https://github.com/mozilla/readability),
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
@@ -249,10 +245,27 @@ Garde-fous :
## Dépannage ## Dépannage
- **`ERREUR : CROWDLENDING_API_KEY est requise`** — la variable d'env n'est - **`En-tête X-API-Key manquant`** — vérifiez l'argument `--header` et la
pas transmise. Vérifiez la section `env` de votre config MCP. variable d'environnement correspondante dans votre config Claude Desktop.
- **« Certains serveurs MCP n'ont pas pu être chargés » / entrée ignorée** —
votre config utilise un bloc `url`/`headers` directement, non supporté par
`claude_desktop_config.json` (voir Développement local plus haut) : il faut
passer par `command: "npx"` + `mcp-remote`.
- **Le serveur reste sur « running » indéfiniment, « Preparing session… » puis
« Could not attach »** — sous Windows, il manque le wrapper `cmd /c` (voir
Développement local). C'est un problème de fond très courant : `npx` y est
un script `.cmd` que `spawn()` ne sait pas exécuter directement, donc le
process est lancé mais ne communique jamais réellement. Symptôme
caractéristique : aucune erreur explicite, juste un blocage silencieux.
- **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte → - **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte →
Clés API et mettez à jour la config. Clés API et mettez à jour la config.
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou - **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou
`CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le `CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le
suffixe `/api/v1`). suffixe `/api/v1`).
- **`Invalid Host` (403)** — l'en-tête `Host` de la requête ne correspond à
aucune valeur de `MCP_ALLOWED_HOSTS`. En local, vérifiez que vous appelez
bien `localhost:4100` (pas `127.0.0.1:4100`, absent de la liste par
défaut — ajoutez-le à `MCP_ALLOWED_HOSTS` si besoin).
- **Session `404` après une longue pause** — la session a expiré après 30
minutes d'inactivité, votre client MCP doit s'y reconnecter (généralement
automatique).
-245
View File
@@ -1,245 +0,0 @@
#!/usr/bin/env node
/**
* Serveur MCP local (stdio) — Crowdlending Tracker
*
* Expose en lecture seule le portefeuille de crowdlending (investissements,
* remboursements, dépôts/retraits, dashboard) à un client MCP (Claude Desktop,
* Claude Code...) via l'API publique /api/v1 du backend.
*
* Authentification : clé API générée dans l'app (Mon compte → Clés API),
* transmise ici via la variable d'environnement CROWDLENDING_API_KEY.
* La clé est scopée à un seul investisseur — ce serveur ne voit donc que
* les données de cet investisseur, jamais l'ensemble du portefeuille famille.
*
* IMPORTANT : ce process communique avec le client MCP via stdout (protocole
* JSON-RPC). Ne jamais utiliser console.log ici — uniquement console.error
* pour les logs de diagnostic (redirigés vers stderr, invisibles du protocole).
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
import { registerDataTools, toolError } from './tools.js';
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
const API_KEY = process.env.CROWDLENDING_API_KEY;
// Étiquette facultative pour distinguer plusieurs instances connectées en
// même temps à Claude Desktop (ex. une pour le backend de dev, une pour la
// prod). Sans elle, on peut toujours faire tourner les deux : Claude Desktop
// namespace déjà chaque outil par le nom du serveur (clé du bloc mcpServers
// dans claude_desktop_config.json), donc pas de collision technique. Le
// LABEL sert surtout à ce que l'agent (et vous) voyiez clairement, dans le
// titre et la description de chaque outil, quel environnement est visé.
const LABEL = (process.env.CROWDLENDING_LABEL || '').trim();
if (!API_KEY) {
console.error('ERREUR : la variable d\'environnement CROWDLENDING_API_KEY est requise.');
console.error('Générez une clé dans l\'app : Mon compte → Clés API.');
process.exit(1);
}
/* ── Client API partagé ───────────────────────────────────────────────── */
async function apiGet(path, params) {
const url = new URL(API_BASE + path);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
}
let res;
try {
res = await fetch(url, {
headers: { 'X-API-Key': API_KEY, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
} catch (e) {
throw new Error(`Impossible de joindre l'API (${API_BASE}) : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Requête échouée';
if (res.status === 401) throw new Error(`Clé API invalide ou révoquée (${msg}). Générez-en une nouvelle dans Mon compte → Clés API.`);
if (res.status === 404) throw new Error(`Ressource introuvable : ${msg}`);
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
return body;
}
/* ── Serveur MCP ──────────────────────────────────────────────────────── */
const server = new McpServer({
name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''),
version: '0.1.0',
});
const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
/** Ajoute la source (URL API + libellé) en fin de description, pour que
* l'agent distingue sans ambiguïté deux instances connectées en parallèle. */
const withSource = (description) =>
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
// Les 6 outils de lecture de données (investisseur, dashboard, investissements,
// remboursements, dépôts/retraits) sont définis dans tools.js, partagés avec
// le serveur HTTP distant — voir ce fichier pour leur documentation.
registerDataTools(server, { apiGet, withLabel, withSource });
/* ── Outil fetch_url (Phase 3) ────────────────────────────────────────────
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via
Readability — la même librairie que le mode lecture de Firefox. L'outil ne
fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/
échéance côté serveur) : il fournit le texte propre, et c'est à l'agent
d'en extraire les informations pertinentes dans la conversation, puis de
les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du
serveur : aucune écriture, la création d'un investissement reste un geste
manuel dans l'app. ────────────────────────────────────────────────────── */
const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue
const FETCH_TIMEOUT_MS = 15000;
const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.1; +local-tool)';
/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire
* en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur
* (y compris son propre backend). Vérif sur le nom d'hôte littéral — ne
* résout pas le DNS, donc pas une protection anti-rebinding DNS complète,
* mais bloque les cas évidents (localhost, IP privées écrites en clair). */
function isBlockedHost(hostname) {
const h = hostname.toLowerCase();
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true;
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = ipv4.slice(1).map(Number);
if (a === 127 || a === 10 || a === 0) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 169 && b === 254) return true;
}
return false;
}
server.registerTool(
'crowdlending_fetch_url',
{
title: 'Lire une page web',
description:
"Récupère une page web (ex. annonce d'un projet sur une plateforme de " +
"crowdlending) et en extrait le contenu lisible (titre + texte principal, " +
"débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " +
"dans l'app — l'agent doit extraire lui-même les informations utiles du " +
"texte retourné (taux, montant, durée, émetteur...) et les proposer à " +
"l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " +
"(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " +
`${CHARACTER_LIMIT} caractères sur les pages très longues.`,
inputSchema: {
url: z.string().url().describe("URL de la page à lire (http/https uniquement)"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ url }) => {
try {
let parsed;
try { parsed = new URL(url); }
catch { throw new Error(`URL invalide : ${url}`); }
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`);
}
if (isBlockedHost(parsed.hostname)) {
throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`);
}
let res;
try {
res = await fetch(parsed, {
headers: {
'User-Agent': FETCH_USER_AGENT,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-FR,fr;q=0.9',
},
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (e) {
// Node/undici masque souvent la vraie cause derrière un message générique
// "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est
// dans e.cause, à remonter explicitement pour un diagnostic utile.
const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : '';
throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`);
}
if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim());
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('html')) {
throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`);
}
const html = await res.text();
const dom = new JSDOM(html, { url: parsed.toString() });
const article = new Readability(dom.window.document).parse();
const title = article?.title || dom.window.document.title || null;
let text = (article?.textContent || dom.window.document.body?.textContent || '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
const fullLength = text.length;
let truncated = false;
if (text.length > CHARACTER_LIMIT) {
text = text.slice(0, CHARACTER_LIMIT);
truncated = true;
}
if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page.");
const output = {
url: parsed.toString(),
title,
site_name: article?.siteName || null,
excerpt: article?.excerpt || null,
text,
length: fullLength,
truncated,
};
return {
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
} catch (e) { return toolError(e); }
},
);
/* ── Démarrage ────────────────────────────────────────────────────────── */
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[crowdlending-mcp-server${LABEL ? `-${LABEL}` : ''}] connecté — API cible : ${API_BASE}`);
}
main().catch((e) => {
console.error('[crowdlending-mcp-server] erreur fatale :', e);
process.exit(1);
});
-3
View File
@@ -15,9 +15,6 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"bin": {
"crowdlending-mcp-server": "index.js"
},
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
+5 -8
View File
@@ -1,16 +1,13 @@
{ {
"name": "crowdlending-mcp-server", "name": "crowdlending-mcp-server",
"version": "0.2.0", "version": "0.2.0",
"description": "Serveur MCP pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Deux modes : local (stdio, index.js) et distant (HTTP, http-server.js).", "description": "Serveur MCP (HTTP) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Même serveur en développement local et en production distante.",
"type": "module", "type": "module",
"main": "index.js", "main": "server.js",
"bin": {
"crowdlending-mcp-server": "./index.js"
},
"scripts": { "scripts": {
"start": "node index.js", "start": "node server.js",
"start:http": "node http-server.js", "dev": "node --watch server.js",
"inspect": "npx @modelcontextprotocol/inspector node index.js" "inspect": "npx @modelcontextprotocol/inspector"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1,33 +1,27 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Serveur MCP distant (HTTP, Streamable HTTP transport) Crowdlending Tracker * Serveur MCP (HTTP, Streamable HTTP transport) Crowdlending Tracker
* *
* Variante "réseau" du serveur local stdio (index.js) : mêmes outils de * Point d'entrée unique, que ce soit en développement local (`npm run dev`,
* lecture (voir tools.js), mais accessible via une URL publique plutôt que * comme le backend et le frontend) ou déployé à distance en production
* comme process enfant sur la machine de l'utilisateur. Pensé pour les * (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
* utilisateurs distants qui ne peuvent/veulent pas installer Node.js et * docker-compose). Un seul modèle de transport, un seul fichier à
* cloner ce dépôt en local ils n'ont qu'à ajouter une URL + leur clé API * maintenir : les outils eux-mêmes vivent dans tools.js.
* personnelle dans la config de leur client MCP (Claude Desktop, etc.).
* *
* DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est * AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
* lancé une fois par utilisateur, avec UNE clé API fixée par variable * l'ancien serveur stdio. Un même process peut servir plusieurs
* d'environnement pour toute la durée du process. Ici, un seul process sert * utilisateurs/sessions en parallèle (typiquement un seul en dev local,
* potentiellement PLUSIEURS utilisateurs distants en parallèle il n'y a * potentiellement plusieurs en déploiement distant) chaque session MCP est
* donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée * initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de
* à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête * la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure
* HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des * des outils de CETTE session uniquement (voir createSession). Deux sessions
* outils de CETTE session uniquement (voir createSession ci-dessous). Deux * ne partagent jamais d'état ni de données.
* utilisateurs distants ne partagent jamais d'état ni de données.
* *
* `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le * Connexion depuis Claude Desktop (dev comme prod) : ce fichier expose du
* détail) : réservé au serveur local, pour limiter le risque SSRF envers * HTTP pur, mais claude_desktop_config.json n'accepte que des entrées
* des utilisateurs tiers non maîtrisés. * command/args la connexion passe donc par le pont mcp-remote
* * (https://github.com/geelen/mcp-remote), voir README.md pour la config
* Sécurité réseau : ce service est prévu pour être exposé directement sur * exacte (et le wrapper cmd /c obligatoire sous Windows).
* internet (sous-domaine dédié, sans la liste blanche d'IP qui protège le
* reste de l'app) la clé API est donc la SEULE barrière. Voir le
* middleware `requireApiKeyHeader` et la validation du Host (protection
* anti DNS-rebinding) plus bas.
*/ */
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -36,28 +30,60 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { registerDataTools, toolError } from './tools.js'; import { registerDataTools, registerFetchUrlTool } from './tools.js';
const PORT = Number(process.env.PORT || 4100); const PORT = Number(process.env.PORT || 4100);
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, ''); const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
// Nom(s) d'hôte public(s) attendus dans l'en-tête Host — protection anti
// DNS-rebinding. Séparés par des virgules si plusieurs (ex. dev + prod). // Étiquette facultative pour distinguer plusieurs instances connectées en
// `localhost` est inclus par défaut pour que le HEALTHCHECK Docker (qui // même temps à Claude Desktop (ex. dev local + prod distante). Affiche
// interroge http://localhost:4100/health depuis l'intérieur du container) // "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
// ne soit pas lui-même bloqué par ce middleware — sans risque côté externe, // de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
// puisque Traefik ne route déjà vers ce service que les requêtes portant le const LABEL = (process.env.MCP_LABEL || '').trim();
// Host public configuré sur son router (une requête Host: localhost envoyée
// depuis l'extérieur n'atteint jamais ce container). // Désactivé par défaut : cet outil lit une URL arbitraire fournie par
// l'appelant, sûr pour un usage perso (vous seul avez la clé API et
// n'atteignez que ce process) mais risqué si le serveur est exposé à des
// tiers non maîtrisés (SSRF). À activer explicitement pour le développement
// local — jamais sur le déploiement distant public (voir docker-compose.yml).
const ENABLE_FETCH_URL = ['1', 'true', 'yes'].includes((process.env.MCP_ENABLE_FETCH_URL || '').toLowerCase());
// Nom(s) d'hôte attendus dans l'en-tête Host — protection anti DNS-rebinding.
// `localhost` est inclus par défaut : nécessaire en développement local
// (connexion directe à localhost:4100) et pour que le HEALTHCHECK Docker
// (qui interroge http://localhost:4100/health depuis l'intérieur du
// container) ne soit pas lui-même bloqué. Sans risque côté externe en
// production : Traefik ne route vers ce service que les requêtes portant le
// Host public configuré sur son router.
const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost') const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
.split(',').map((h) => h.trim()).filter(Boolean); .split(',').map((h) => h.trim()).filter(Boolean);
// Durée d'inactivité au-delà de laquelle une session orpheline est fermée // Durée d'inactivité au-delà de laquelle une session orpheline est fermée
// (client parti sans DELETE explicite — évite une fuite mémoire lente). // (client parti sans DELETE explicite — évite une fuite mémoire lente).
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
console.error(`[crowdlending-mcp-remote] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}`); console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`);
/** Préfixe commun à toutes les lignes de log applicatif. */
const LOG_PREFIX = `[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}]`;
/** Résumé lisible d'une requête JSON-RPC entrante (nom d'outil pour un
* tools/call, méthode sinon), pour le log applicatif jamais la clé API. */
function describeRequest(body) {
if (!body || typeof body !== 'object') return 'requête inconnue';
if (body.method === 'tools/call') return `tools/call ${body.params?.name || '?'}`;
return body.method || 'requête inconnue';
}
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
/** Ajoute la source (URL API + libellé) en fin de description. */
const withSource = (description) =>
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
/* Client API : une closure par session, liée à LA clé API de cette /* Client API : une closure par session, liée à LA clé API de cette
session (jamais un module-level constant, contrairement à index.js). */ session (jamais un module-level constant). */
function makeApiGet(apiKey) { function makeApiGet(apiKey) {
return async function apiGet(path, params) { return async function apiGet(path, params) {
const url = new URL(API_BASE + path); const url = new URL(API_BASE + path);
@@ -104,7 +130,7 @@ setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [sessionId, s] of sessions.entries()) { for (const [sessionId, s] of sessions.entries()) {
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) { if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) {
console.error(`[crowdlending-mcp-remote] session ${sessionId} inactive depuis plus de 30 min, fermeture.`); console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)} inactive depuis plus de 30 min, fermeture.`);
s.transport.close(); s.transport.close();
sessions.delete(sessionId); sessions.delete(sessionId);
} }
@@ -113,11 +139,9 @@ setInterval(() => {
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */ /** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
async function createSession(apiKey) { async function createSession(apiKey) {
const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' }); const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
registerDataTools(server, { registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
apiGet: makeApiGet(apiKey), if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`,
});
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
@@ -137,8 +161,7 @@ async function createSession(apiKey) {
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
// Limite basique anti-abus : une clé compromise ou un client buggé ne doit // Limite basique anti-abus : une clé compromise ou un client buggé ne doit
// pas pouvoir marteler l'API backend sans frein. Comptabilisé par IP (la // pas pouvoir marteler l'API backend sans frein.
// clé API n'est lue qu'après ce middleware).
app.use(rateLimit({ app.use(rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
limit: 60, limit: 60,
@@ -168,7 +191,10 @@ app.post('/mcp', async (req, res) => {
return; return;
} }
touchSession(sessionId); touchSession(sessionId);
const label = describeRequest(req.body);
const startedAt = Date.now();
await session.transport.handleRequest(req, res, req.body); await session.transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)}${label}${res.statusCode} (${Date.now() - startedAt}ms)`);
return; return;
} }
@@ -184,9 +210,11 @@ app.post('/mcp', async (req, res) => {
} }
const transport = await createSession(apiKey); const transport = await createSession(apiKey);
const startedAt = Date.now();
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} nouvelle session ${transport.sessionId ? transport.sessionId.slice(0, 8) : '?'} — initialize — ${res.statusCode} (${Date.now() - startedAt}ms)`);
} catch (e) { } catch (e) {
console.error('[crowdlending-mcp-remote] erreur /mcp POST :', e); console.error(`${LOG_PREFIX} erreur /mcp POST :`, e);
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null }); if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
} }
}); });
@@ -209,5 +237,5 @@ app.get('/mcp', handleExistingSession);
app.delete('/mcp', handleExistingSession); app.delete('/mcp', handleExistingSession);
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.error(`[crowdlending-mcp-remote] à l'écoute sur le port ${PORT}`); console.error(`${LOG_PREFIX} à l'écoute sur le port ${PORT}`);
}); });
+158 -13
View File
@@ -1,19 +1,26 @@
/** /**
* Outils MCP de lecture de données partagés entre le serveur local (stdio, * Outils MCP partagés par le serveur unique (server.js, HTTP), que ce soit
* index.js) et le serveur distant (HTTP, http-server.js). * en développement local (npm run dev, localhost:4100) ou déployé à distance
* (mcp.crowdlending.croguennec.net).
* *
* Ce module ne contient AUCUNE logique de transport ni d'authentification : * Ce module ne contient AUCUNE logique de transport ni d'authentification :
* il reçoit un `apiGet(path, params)` déjà prêt à l'emploi (déjà lié à une * il reçoit un `apiGet(path, params)` déjà prêt à l'emploi (déjà lié à une
* clé API et une base URL précises) et enregistre les 6 outils de lecture * clé API et une base URL précises) et enregistre les outils sur le
* sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas * `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
* diverger entre les deux serveurs un seul endroit à maintenir. * selon l'environnement un seul endroit à maintenir.
* *
* `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local * `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
* (voir index.js) l'exposer à des utilisateurs distants tiers via le * de façon CONDITIONNELLE par server.js (variable d'env
* serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage. * `MCP_ENABLE_FETCH_URL`) : cet outil lit une URL arbitraire fournie par
* l'appelant, ce qui est sûr pour un usage perso (vous seul pouvez y
* accéder) mais présente un risque SSRF si exposé à des utilisateurs
* distants non maîtrisés désactivé par défaut, à activer explicitement
* pour le développement local.
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
const READ_ONLY_ANNOTATIONS = { const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true, readOnlyHint: true,
@@ -44,7 +51,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
description: withSource( description: withSource(
"Retourne le profil de l'investisseur associé à la clé API utilisée " + "Retourne le profil de l'investisseur associé à la clé API utilisée " +
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " + "(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
"quel portefeuille portent les autres outils."), "quel portefeuille portent les autres outils. Avec une clé « Famille et " +
"entreprises » (scope agrégé), retourne une liste de profils (un par " +
"membre du foyer) au lieu d'un profil unique — dans ce cas, les autres " +
"outils portent sur l'ensemble des membres, pas un seul."),
inputSchema: {}, inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS, annotations: READ_ONLY_ANNOTATIONS,
}, },
@@ -170,13 +180,148 @@ function toolResult(data) {
}; };
} }
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. /** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */
* Exporté pour être réutilisé tel quel par le serveur HTTP distant (mêmes
* messages d'erreur que le serveur local, pour ne pas dérouter l'agent
* selon le transport utilisé). */
export function toolError(e) { export function toolError(e) {
return { return {
content: [{ type: 'text', text: `Erreur : ${e.message}` }], content: [{ type: 'text', text: `Erreur : ${e.message}` }],
isError: true, isError: true,
}; };
} }
/* Outil fetch_url (Phase 3) enregistrement conditionnel
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via
Readability la même librairie que le mode lecture de Firefox. L'outil ne
fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/
échéance côté serveur) : il fournit le texte propre, et c'est à l'agent
d'en extraire les informations pertinentes dans la conversation, puis de
les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du
serveur : aucune écriture, la création d'un investissement reste un geste
manuel dans l'app. */
const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue
const FETCH_TIMEOUT_MS = 15000;
const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.2; +local-tool)';
/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire
* en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur
* (y compris son propre backend). Vérif sur le nom d'hôte littéral ne
* résout pas le DNS, donc pas une protection anti-rebinding DNS complète,
* mais bloque les cas évidents (localhost, IP privées écrites en clair). */
function isBlockedHost(hostname) {
const h = hostname.toLowerCase();
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true;
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = ipv4.slice(1).map(Number);
if (a === 127 || a === 10 || a === 0) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 169 && b === 254) return true;
}
return false;
}
/** Enregistre `crowdlending_fetch_url` sur `server`. À n'appeler que si
* `MCP_ENABLE_FETCH_URL` est activé côté server.js voir note en tête de
* fichier. */
export function registerFetchUrlTool(server) {
server.registerTool(
'crowdlending_fetch_url',
{
title: 'Lire une page web',
description:
"Récupère une page web (ex. annonce d'un projet sur une plateforme de " +
"crowdlending) et en extrait le contenu lisible (titre + texte principal, " +
"débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " +
"dans l'app — l'agent doit extraire lui-même les informations utiles du " +
"texte retourné (taux, montant, durée, émetteur...) et les proposer à " +
"l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " +
"(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " +
`${CHARACTER_LIMIT} caractères sur les pages très longues.`,
inputSchema: {
url: z.string().url().describe("URL de la page à lire (http/https uniquement)"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ url }) => {
try {
let parsed;
try { parsed = new URL(url); }
catch { throw new Error(`URL invalide : ${url}`); }
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`);
}
if (isBlockedHost(parsed.hostname)) {
throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`);
}
let res;
try {
res = await fetch(parsed, {
headers: {
'User-Agent': FETCH_USER_AGENT,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-FR,fr;q=0.9',
},
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (e) {
// Node/undici masque souvent la vraie cause derrière un message générique
// "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est
// dans e.cause, à remonter explicitement pour un diagnostic utile.
const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : '';
throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`);
}
if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim());
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('html')) {
throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`);
}
const html = await res.text();
const dom = new JSDOM(html, { url: parsed.toString() });
const article = new Readability(dom.window.document).parse();
const title = article?.title || dom.window.document.title || null;
let text = (article?.textContent || dom.window.document.body?.textContent || '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
const fullLength = text.length;
let truncated = false;
if (text.length > CHARACTER_LIMIT) {
text = text.slice(0, CHARACTER_LIMIT);
truncated = true;
}
if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page.");
const output = {
url: parsed.toString(),
title,
site_name: article?.siteName || null,
excerpt: article?.excerpt || null,
text,
length: fullLength,
truncated,
};
return {
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
} catch (e) { return toolError(e); }
},
);
}