Files
crowdlending-app/mcp-server/tools.js
T
2026-09-19 17:34:09 +02:00

523 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Outils MCP — partagés par le serveur unique (server.js, HTTP), que ce soit
* 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 :
* 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 outils sur le
* `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
* selon l'environnement — un seul endroit à maintenir.
*
* `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
* de façon CONDITIONNELLE par server.js (variable d'env
* `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 { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};
/**
* Enregistre les 11 outils de lecture de données sur `server`.
*
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
* @param {object} ctx
* @param {(path: string, params?: object) => Promise<any>} ctx.apiGet
* Fonction d'appel à l'API v1, déjà authentifiée pour ce serveur/session.
* @param {(title: string) => string} [ctx.withLabel]
* Ajoute un libellé d'environnement au titre d'un outil (ex. "[PROD]").
* Par défaut : identité (pas de libellé).
* @param {(description: string) => string} [ctx.withSource]
* Ajoute la source de données en fin de description.
* Par défaut : identité (pas de source affichée).
*/
export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSource = (d) => d }) {
server.registerTool(
'crowdlending_get_investisseur',
{
title: withLabel('Profil investisseur'),
description: withSource(
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
"quel portefeuille portent les autres outils. 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,
},
async () => {
try { return toolResult(await apiGet('/investisseur')); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_dashboard',
{
title: withLabel('Synthèse du portefeuille'),
description: withSource(
"Retourne les KPIs du portefeuille : nombre d'investissements, total " +
"investi, capital investi actuel (= capital restant dû sur les prêts en " +
"cours et en défaut, net des remboursements déjà perçus — équivalent au " +
"KPI \"Capital investi\" de l'app), capital en risque (sous-ensemble en " +
"retard/procédure), montant remboursé, intérêts bruts/nets perçus, " +
"capital reçu, total dépôts/retraits, et gain_net_depuis_debut (intérêts " +
"nets + cashback/bonus de parrainage ou de plateforme + corrections de " +
"solde − tous les frais réellement payés en dehors du passage brut → net " +
"d'un remboursement (à la souscription, porte-monnaie ou compte courant " +
"du détenteur ; seul le mode \"remboursement\" est exclu, déjà déduit des " +
"intérêts nets ; détail dans gain_net_depuis_debut.frais_hors_remboursement " +
"— voir aussi crowdlending_list_frais_operations), " +
"cumulés sur tout l'historique — équivalent au KPI \"Gain net " +
"depuis le début\" de l'app, toujours un total, jamais filtré par année, " +
"reste valable même après un retrait complet des plateformes). Les " +
"montants d'investissements sont des soldes actuels (photo à " +
"aujourd'hui), pas des cumuls par période. Depuis le 20/09/26, une clé " +
"investissements_pe distincte résume le portefeuille Private Equity " +
"(nb_deals, total_investi, valorisation_totale, distributions_cumulees, " +
"tvpi/dpi/rvpi) — ces chiffres ne sont PAS inclus dans les champs " +
"ci-dessus (investissements, cash, gain_net_depuis_debut), qui restent " +
"strictement crowdlending ; voir crowdlending_list_investissements_pe " +
"pour le détail par deal. Point d'entrée idéal pour " +
"une vue d'ensemble avant d'aller chercher le détail."),
inputSchema: {
annee: z.number().int().optional()
.describe("Filtre les intérêts/capital reçu sur une année (ex. 2026). Omettre pour le cumul total. N'affecte pas le capital investi/en risque, qui sont toujours des soldes actuels."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ annee }) => {
try { return toolResult(await apiGet('/dashboard', { annee })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_investissements',
{
title: withLabel('Liste des investissements'),
description: withSource(
"Liste les investissements (prêts participatifs) du portefeuille : " +
"projet, émetteur, plateforme, montant investi, taux, durée, statut. " +
"Filtrable par statut. Ne renvoie pas les remboursements détaillés — " +
"utiliser crowdlending_get_investissement pour le détail d'un projet."),
inputSchema: {
statut: z.enum(['en_cours', 'rembourse', 'en_retard', 'procedure', 'cloture', 'perte_definitive', 'prolongation'])
.optional()
.describe("Filtrer par statut. Omettre pour lister tous les investissements."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ statut }) => {
try { return toolResult(await apiGet('/investissements', { statut })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_investissement',
{
title: withLabel("Détail d'un investissement"),
description: withSource(
"Retourne le détail complet d'un investissement (identifié par son id, " +
"obtenu via crowdlending_list_investissements), y compris la liste de " +
"ses remboursements réels perçus (capital, intérêts bruts/nets, cashback, " +
"et frais_ttc/frais_mode_reglement — le frais ne réduit interets_nets/" +
"net_recu que si frais_mode_reglement vaut \"remboursement\") et le " +
"tableau frais_operations : tous les frais liés à cet investissement " +
"(souscription, gestion, distribution, autre), quel que soit leur mode " +
"de règlement (source, porte-monnaie, compte courant, ou retenu sur un " +
"remboursement)."),
inputSchema: {
id: z.number().int().positive().describe("Identifiant de l'investissement"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
try { return toolResult(await apiGet(`/investissements/${id}`)); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_investissements_pe',
{
title: withLabel('Liste des investissements Private Equity'),
description: withSource(
"Liste les investissements Private Equity (deals) du portefeuille — distinct de " +
"crowdlending_list_investissements : un deal PE n'a ni échéancier connu à l'avance ni " +
"taux d'intérêt fixe, c'est un montant unique investi (montant_investi), valorisé " +
"périodiquement (derniere_valorisation, derniere_valorisation_date), avec des " +
"distributions perçues au fil du temps (distributions_cumulees, dont un sous-ensemble " +
"obligation_cumulee = intérêts sur capital non encore déployé). tvpi (Total Value to " +
"Paid-In = (dernière valorisation + distributions cumulées) / montant investi) est null " +
"tant qu'aucune valorisation n'a été saisie pour ce deal. Filtrable par statut et par " +
"workspace. Ne renvoie pas l'historique des valorisations ni le détail des distributions " +
"— utiliser crowdlending_get_investissement_pe pour le détail d'un deal."),
inputSchema: {
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive'])
.optional()
.describe("Filtrer par statut. Omettre pour lister tous les deals."),
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (utile si plusieurs workspaces Private Equity existent). Absent : tous confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ statut, workspace }) => {
try { return toolResult(await apiGet('/investissements-pe', { statut, workspace })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_investissement_pe',
{
title: withLabel("Détail d'un investissement Private Equity"),
description: withSource(
"Retourne le détail complet d'un deal Private Equity (identifié par son id, obtenu via " +
"crowdlending_list_investissements_pe), y compris valorisations (historique complet — " +
"la plus récente est celle utilisée pour derniere_valorisation/tvpi dans la liste), " +
"remboursements (distributions perçues : retour de capital, plus-value) et " +
"frais_operations (tous modes de règlement confondus)."),
inputSchema: {
id: z.number().int().positive().describe("Identifiant de l'investissement PE"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
try { return toolResult(await apiGet(`/investissements-pe/${id}`)); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_remboursements',
{
title: withLabel('Liste des remboursements'),
description: withSource(
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
"avec le nom du projet et de la plateforme. Filtrable par période et par " +
"workspace (paramètre workspace ; sans ce paramètre, tous workspaces " +
"confondus). Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
"remboursement) et frais_mode_reglement : le frais ne réduit " +
"interets_nets/net_recu que si frais_mode_reglement vaut " +
"\"remboursement\" — avec \"portefeuille\" ou \"compte_courant\", le frais " +
"est réglé à part (voir crowdlending_list_frais_operations) et n'a " +
"aucun impact sur ce remboursement, ne pas le soustraire une seconde fois."),
inputSchema: {
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de début au format YYYY-MM-DD (incluse)'),
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ date_debut, date_fin, workspace }) => {
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin, workspace })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_frais_operations',
{
title: withLabel('Liste des frais liés aux investissements'),
description: withSource(
"Liste les frais facturés par certaines plateformes en dehors des " +
"intérêts/prélèvements fiscaux habituels : frais d'entrée à la " +
"souscription, frais de gestion, frais sur distribution, ou autre " +
"(catégorie renseignée dans `categorie`). `mode_reglement` indique " +
"comment le frais a été réglé — \"source\" : aucun mouvement de " +
"trésorerie réel, montant purement indicatif (déjà déduit par la " +
"plateforme en amont) ; \"portefeuille\" : débité du porte-monnaie de " +
"la plateforme ; \"compte_courant\" : débité d'un compte courant du " +
"détenteur (`compte_nom`) ; \"remboursement\" : retenu directement sur " +
"le remboursement lié (`remboursement_id`), déjà pris en compte dans " +
"les champs interets_nets/net_recu de ce remboursement (voir " +
"crowdlending_list_remboursements) — ne pas le compter une seconde " +
"fois. Tous les autres modes (\"source\", \"portefeuille\", " +
"\"compte_courant\") représentent chacun une sortie d'argent réelle : " +
"c'est cet ensemble (tout sauf \"remboursement\") qui est déduit de " +
"gain_net_depuis_debut sur crowdlending_get_dashboard. Depuis le " +
"20/09/26, couvre aussi bien les frais crowdlending que les frais " +
"Private Equity — `source_type` (\"crowdlending\" ou \"private_equity\") " +
"indique de quel côté vient chaque ligne (une ligne crowdlending porte " +
"investissement_id/nom_projet, une ligne PE porte investissement_pe_id/" +
"nom_deal). Filtrable par période et par workspace (paramètre workspace ; " +
"sans ce paramètre, les deux univers sont renvoyés ensemble)."),
inputSchema: {
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de début au format YYYY-MM-DD (incluse)'),
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (ex. "crowdlending" ou "private-equity"). Absent : les deux univers confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ date_debut, date_fin, workspace }) => {
try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin, workspace })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_depots_retraits',
{
title: withLabel('Liste des dépôts / retraits'),
description: withSource(
"Liste les mouvements de cash (dépôts et retraits) sur les plateformes " +
"de crowdlending, du plus récent au plus ancien. Filtrable par workspace " +
"(paramètre workspace) ; sans ce paramètre, tous workspaces confondus."),
inputSchema: {
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ workspace }) => {
try { return toolResult(await apiGet('/depots-retraits', { workspace })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_objectifs',
{
title: withLabel('Objectifs de versement et de capital investi'),
description: withSource(
"Liste les objectifs fixés dans Paramétrage → Fixation des objectifs, à " +
"comparer au réalisé (voir crowdlending_get_dashboard ou " +
"crowdlending_list_depots_retraits). Trois types : versement_global " +
"(objectif global sur l'investisseur principal), versement_annuel " +
"(enveloppe par détenteur), capital_investi (objectif de capital investi " +
"par plateforme, avec plateforme_id renseigné). Liste vide si la " +
"fonctionnalité est désactivée par l'utilisateur, ou si aucun objectif " +
"n'a encore été saisi."),
inputSchema: {
type: z.enum(['versement_global', 'versement_annuel', 'capital_investi'])
.optional()
.describe("Filtrer par type d'objectif. Omettre pour lister tous les objectifs."),
annee: z.number().int().optional()
.describe("Filtrer par année. Omettre pour lister toutes les années."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ type, annee }) => {
try { return toolResult(await apiGet('/objectifs', { type, annee })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_taxreport',
{
title: withLabel('Synthèse fiscale annuelle (CERFA)'),
description: withSource(
"Retourne une synthèse fiscale annuelle pour aider à préparer la " +
"déclaration de revenus — CE N'EST PAS UNE SOURCE OFFICIELLE : les " +
"montants doivent être vérifiés avant d'être reportés, l'utilisateur " +
"reste seul responsable de l'exactitude de sa déclaration. Reprend les " +
"mêmes calculs que la page Fiscalité de l'application. `synthese_2042` " +
"donne les cases 2TT/2TR/2BH/2CK/2TY à reporter sur la déclaration " +
"principale (CERFA 2042). `cerfa2561` détaille le récapitulatif IFU des " +
"plateformes françaises soumises au prélèvement forfaitaire unique " +
"(PFU). `cerfa2047` (présent seulement si has_etranger est vrai) détaille " +
"par plateforme étrangère l'éligibilité au crédit d'impôt conventionnel " +
"(cases 231 à 238, ou 250 si non éligible). `cerfa2778_mensuel` (présent " +
"seulement si has_etranger) donne le détail mensuel des intérêts bruts " +
"par plateforme étrangère, base du calcul du prélèvement forfaitaire " +
"obligatoire (PFO) — un acompte mensuel légalement dû seulement si " +
"pfo_assujetti est vrai (seuil de revenu fiscal de référence : 25 000 € " +
"pour une personne seule, 50 000 € pour un couple marié ou pacsé), mais " +
"la déclaration annuelle CERFA 2047 reste obligatoire dans tous les cas " +
"dès qu'il y a des revenus de plateformes étrangères. Limite connue : " +
"case_2BH/case_2CK de synthese_2042 supposent qu'aucune plateforme " +
"étrangère n'a été exclue manuellement du calcul PFO dans l'application " +
"(réglage propre au navigateur, non accessible depuis l'API)."),
inputSchema: {
annee: z.number().int().optional()
.describe("Année fiscale (ex. 2026). Omettre pour l'année précédente par défaut."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ annee }) => {
try { return toolResult(await apiGet('/taxreport', { annee })); }
catch (e) { return toolError(e); }
},
);
}
/** Formate le résultat d'un outil : texte JSON lisible + structuredContent.
* Le protocole MCP exige que `structuredContent` soit un objet JSON (pas un
* tableau brut) — les endpoints qui renvoient une liste sont enveloppés
* dans { items: [...] }. */
function toolResult(data) {
const structuredContent = Array.isArray(data) ? { items: data } : data;
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent,
};
}
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */
export function toolError(e) {
return {
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
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); }
},
);
}