523 lines
25 KiB
JavaScript
523 lines
25 KiB
JavaScript
/**
|
||
* 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); }
|
||
},
|
||
);
|
||
}
|