diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 0919672..6fe09e2 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2279,4 +2279,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_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; diff --git a/backend/src/middleware/apiKey.js b/backend/src/middleware/apiKey.js index b102bfd..89ee1b8 100644 --- a/backend/src/middleware/apiKey.js +++ b/backend/src/middleware/apiKey.js @@ -3,9 +3,16 @@ import db from '../db/index.js'; /** * Authentification par clé API (X-API-Key), distincte du JWT utilisé par le - * frontend (requireAuth). Une clé API est toujours scopée à un seul - * investisseur — pas de notion de "scope=all" ici, contrairement aux routes - * internes. Réservé aux routes /api/v1 (lecture seule, Phase 1). + * frontend (requireAuth). Une clé API est scopée soit à un seul investisseur, + * soit à "Famille et entreprises" (scope_all=1, réservé au profil principal + * — 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) { 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 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 WHERE k.key_hash = ? `).get(hash); @@ -26,8 +33,10 @@ export function requireApiKey(req, res, next) { db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id); - req.apiKeyId = row.id; - req.investisseurId = row.investisseur_id; - req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim()); + req.apiKeyId = row.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()); next(); } diff --git a/backend/src/routes/apiKeys.js b/backend/src/routes/apiKeys.js index cd2fa58..e7102a9 100644 --- a/backend/src/routes/apiKeys.js +++ b/backend/src/routes/apiKeys.js @@ -17,7 +17,7 @@ function generateKey() { /* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */ router.get('/', (req, res) => { 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 FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id @@ -27,29 +27,37 @@ router.get('/', (req, res) => { 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) => { try { const nom = (req.body?.nom || '').trim(); 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.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'); - 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); 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 info = db.prepare(` - INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes) - VALUES (?, ?, ?, ?, ?, 'read') - `).run(req.user.id, investisseur_id, nom, prefix, hash); + INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all) + VALUES (?, ?, ?, ?, ?, 'read', ?) + `).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0); 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 FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_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); 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 FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id WHERE k.id = ? diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js index df144e1..349d2c5 100644 --- a/backend/src/routes/v1/dashboard.js +++ b/backend/src/routes/v1/dashboard.js @@ -27,9 +27,17 @@ const router = Router(); * 200: { description: Synthèse KPI } */ router.get('/', (req, res) => { - const invId = req.investisseurId; 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 // en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours + // 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) END), 0) AS capital_en_risque, COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse - FROM investissements i WHERE i.investisseur_id = ? - `).get(invId); + FROM investissements i WHERE ${invCond('i.investisseur_id')} + `).get(invParam); - const interetsConds = ['i.investisseur_id = ?']; - const interetsParams = [invId]; + const interetsConds = [invCond('i.investisseur_id')]; + const interetsParams = [invParam]; if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); } const interets = db.prepare(` @@ -73,8 +81,8 @@ router.get('/', (req, res) => { SELECT 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 - FROM depots_retraits WHERE investisseur_id = ? - `).get(invId); + FROM depots_retraits WHERE ${invCond('investisseur_id')} + `).get(invParam); res.json({ investissements, interets: { ...interets, annee: annee || null }, cash }); }); diff --git a/backend/src/routes/v1/depotsRetraits.js b/backend/src/routes/v1/depotsRetraits.js index 49098cf..95b12d8 100644 --- a/backend/src/routes/v1/depotsRetraits.js +++ b/backend/src/routes/v1/depotsRetraits.js @@ -14,14 +14,21 @@ const router = Router(); * 200: { description: Liste des mouvements } */ 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(` SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type, dr.montant, dr.libelle FROM depots_retraits dr JOIN plateformes p ON p.id = dr.plateforme_id - WHERE dr.investisseur_id = ? + WHERE ${invCond} ORDER BY dr.date_operation DESC - `).all(req.investisseurId); + `).all(invParam); res.json(rows); }); diff --git a/backend/src/routes/v1/investissements.js b/backend/src/routes/v1/investissements.js index c8bd28d..bf190bf 100644 --- a/backend/src/routes/v1/investissements.js +++ b/backend/src/routes/v1/investissements.js @@ -27,8 +27,12 @@ const LIST_COLUMNS = ` */ router.get('/', (req, res) => { const { statut } = req.query; - const conds = ['i.investisseur_id = ?']; - const args = [req.investisseurId]; + // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du + // 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); } const rows = db.prepare(` @@ -59,12 +63,17 @@ router.get('/', (req, res) => { */ router.get('/:id', (req, res, next) => { 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(` SELECT ${LIST_COLUMNS}, i.notes FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id - WHERE i.id = ? AND i.investisseur_id = ? - `).get(req.params.id, req.investisseurId); + WHERE i.id = ? AND ${invCond} + `).get(req.params.id, invParam); if (!inv) throw new HttpError(404, 'Investissement introuvable'); const remboursements = db.prepare(` diff --git a/backend/src/routes/v1/investisseur.js b/backend/src/routes/v1/investisseur.js index af01930..1f2f173 100644 --- a/backend/src/routes/v1/investisseur.js +++ b/backend/src/routes/v1/investisseur.js @@ -7,16 +7,30 @@ const router = Router(); * @openapi * /investisseur: * 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] * security: [{ ApiKeyAuth: [] }] * responses: * 200: - * description: Profil investisseur + * description: Profil investisseur, ou liste de profils si scope_all * 401: * description: Clé API invalide ou manquante */ 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(` SELECT id, nom, prenom, type, type_fiscal, notes, created_at FROM investisseurs WHERE id = ? diff --git a/backend/src/routes/v1/remboursements.js b/backend/src/routes/v1/remboursements.js index a71bf01..75cb057 100644 --- a/backend/src/routes/v1/remboursements.js +++ b/backend/src/routes/v1/remboursements.js @@ -22,8 +22,12 @@ const router = Router(); */ router.get('/', (req, res) => { const { date_debut, date_fin } = req.query; - const conds = ['i.investisseur_id = ?']; - const args = [req.investisseurId]; + // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du + // 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_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); } diff --git a/frontend/src/pages/Aide.jsx b/frontend/src/pages/Aide.jsx index 65a52fe..38920a4 100644 --- a/frontend/src/pages/Aide.jsx +++ b/frontend/src/pages/Aide.jsx @@ -247,7 +247,7 @@ export default function Aide() { crowdlending_get_investisseur - Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal). + 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. diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index cddfe16..f19d205 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -805,6 +805,12 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) { const [busy, setBusy] = useState(false); 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(() => { if (open) { setNom(''); @@ -813,6 +819,8 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) { } }, [open, investisseurs]); + const isScopeAll = investisseurId === 'all'; + const submit = async (e) => { e.preventDefault(); if (!nom.trim()) return setErr('Le nom de la clé est requis'); @@ -820,7 +828,10 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) { setBusy(true); setErr(null); 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); } catch (e) { setErr(e.message); } finally { setBusy(false); } @@ -841,10 +852,13 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) { {investisseurs.map(i => ( ))} + {principal && }

- 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."}

@@ -1043,7 +1057,7 @@ function ApiKeysSection() { {keys.map(k => ( {k.nom} - {k.investisseur_nom} + {k.scope_all ? 'Famille et entreprises' : k.investisseur_nom} {k.key_prefix}… {fmtKeyDate(k.created_at)} {fmtKeyDate(k.last_used_at)} @@ -1149,10 +1163,12 @@ function CopyBlock({ text }) { } function McpServerSection({ goToApiKeys }) { + const navigate = useNavigate(); const [mcpUrl, setMcpUrl] = useState(guessMcpUrl()); const [apiKey, setApiKey] = useState(''); 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-.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 detectedLabel = detectLabelFromUrl(mcpUrl); const label = manualLabel ?? detectedLabel; @@ -1177,11 +1193,20 @@ function McpServerSection({ goToApiKeys }) { // 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 || '', + // 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({ mcpServers: { [serverKey]: isWindows - ? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: { [envVarName]: apiKey || '' } } - : { command: 'npx', args: mcpRemoteArgs, env: { [envVarName]: apiKey || '' } }, + ? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: envBlock } + : { command: 'npx', args: mcpRemoteArgs, env: envBlock }, }, }, null, 2); @@ -1261,12 +1286,15 @@ function McpServerSection({ goToApiKeys }) { action nécessaire au-delà de renseigner l'URL et votre clé API.

)} -

- Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et - prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé - API distincte) — la clé de config ({serverKey} ci-dessous) s'ajuste automatiquement, - Claude Desktop ne confondra jamais les deux portefeuilles. -

+ {isLocal && ( +

+ 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 + ({serverKey} ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais + les deux connexions. +

+ )}

3. Copier la configuration

@@ -1295,10 +1323,27 @@ function McpServerSection({ goToApiKeys }) { 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'; }} /> - +

+ +

+ Recommandé en cas de problème — voir Dépannage ci-dessous. +

+
+ {!isLocal && ( +
+ +

+ 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. +

+
+ )}

4. Redémarrer Claude Desktop

@@ -1307,12 +1352,22 @@ function McpServerSection({ goToApiKeys }) {

5. Vérifier

-

- Dans la liste des outils MCP de Claude Desktop, les outils crowdlending_* doivent - apparaître (6 en production, 7 en développement local si crowdlending_fetch_url est - activé — voir mcp-server/README.md). Testez avec une question du type « Quel est mon - encours de crowdlending actuellement ? ». +

+ Demandez à Claude : « Peux-tu me lister les outils auxquels + tu as accès ? » — 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 crowdlending_* doivent apparaître dans sa réponse + {isLocal + ? <> (6, ou 7 si crowdlending_fetch_url est activé — voir mcp-server/README.md) + : ' (6 au total)'}.

+

+ 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. +

+

6. Dépannage

@@ -1341,13 +1396,40 @@ function McpServerSection({ goToApiKeys }) { l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).

-

Vérifier côté serveur

-

- La console du serveur (le terminal où tourne npm run dev) 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é - npx/mcp-remote (voir ci-dessus), pas dans server.js. -

+ {!isLocal && ( + <> +

+ Connexion refusée avec une erreur de certificat, alors que le serveur répond bien +

+

+ Symptôme différent du précédent : la config est correcte, mais mcp-remote échoue à + joindre l'URL de production elle-même (pas le registre npm cette fois) avec une erreur + UNABLE_TO_VERIFY_LEAF_SIGNATURE 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 NODE_OPTIONS=--use-system-ca + 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 node -v) ; sur une version + plus ancienne, il faut exporter le certificat racine de l'antivirus (certmgr.msc → + Autorités de certification racines de confiance → Exporter en Base-64 X.509) et le référencer via + NODE_EXTRA_CA_CERTS à la place. Évitez de désactiver complètement la vérification + TLS (NODE_TLS_REJECT_UNAUTHORIZED=0) : cette connexion sort sur internet avec votre + clé API et vos données, contrairement au développement local en localhost. +

+ + )} + + {isLocal && ( + <> +

Vérifier côté serveur

+

+ La console du serveur (le terminal où tourne npm run dev) 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é + npx/mcp-remote (voir ci-dessus), pas dans server.js. +

+ + )}
); } diff --git a/mcp-server/README.md b/mcp-server/README.md index 3c8304a..e7248b3 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -207,7 +207,7 @@ Tous en lecture seule (`readOnlyHint: true`) : | 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_list_investissements` | Liste des investissements, filtrable par statut | | `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements | diff --git a/mcp-server/tools.js b/mcp-server/tools.js index a4943b1..ebc0149 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -51,7 +51,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo description: withSource( "Retourne le profil de l'investisseur associé à la clé API utilisée " + "(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " + - "quel portefeuille portent les autres outils."), + "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: {}, annotations: READ_ONLY_ANNOTATIONS, },