Possibilité d'avoir une clé API Globale par famille

This commit is contained in:
2026-07-20 18:47:58 +02:00
parent 9eb19efd92
commit 441da1975a
12 changed files with 224 additions and 62 deletions
+18
View File
@@ -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;
+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
* 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);
@@ -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);
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());
next();
}
+16 -8
View File
@@ -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 = ?
+15 -7
View File
@@ -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 });
});
+9 -2
View File
@@ -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);
});
+13 -4
View File
@@ -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(`
+16 -2
View File
@@ -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 = ?
+6 -2
View File
@@ -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); }
+1 -1
View File
@@ -247,7 +247,7 @@ export default function Aide() {
<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).</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' }}>
+98 -16
View File
@@ -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 => (
<option key={i.id} value={i.id}>{memberLabel(i)}</option>
))}
{principal && <option value="all">Famille et entreprises</option>}
</select>
</div>
<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>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
@@ -1043,7 +1057,7 @@ function ApiKeysSection() {
{keys.map(k => (
<tr key={k.id}>
<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>{fmtKeyDate(k.created_at)}</td>
<td>{fmtKeyDate(k.last_used_at)}</td>
@@ -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-<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 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 || '<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({
mcpServers: {
[serverKey]: isWindows
? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' } }
: { command: 'npx', args: mcpRemoteArgs, env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' } },
? { 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.
</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 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 (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
Claude Desktop ne confondra jamais les deux portefeuilles.
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>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
@@ -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'; }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '0 0 10px', fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<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> (recommandé en cas de problème — voir Dépannage ci-dessous)
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} />
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
@@ -1307,12 +1352,22 @@ function McpServerSection({ goToApiKeys }) {
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
apparaître (6 en production, 7 en développement local si <code>crowdlending_fetch_url</code> est
activé voir <code>mcp-server/README.md</code>). Testez avec une question du type « Quel est mon
encours de crowdlending actuellement ? ».
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Demandez à Claude : « <strong style={{ color: 'var(--text)' }}>Peux-tu me lister les outils auxquels
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 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>
@@ -1341,6 +1396,31 @@ function McpServerSection({ goToApiKeys }) {
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
@@ -1348,6 +1428,8 @@ function McpServerSection({ goToApiKeys }) {
é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>
);
}
+1 -1
View File
@@ -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 |
+4 -1
View File
@@ -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,
},