/** * 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 6 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} 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."), 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. Les montants d'investissements sont " + "des soldes actuels (photo à aujourd'hui), pas des cumuls par période. " + "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']) .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)."), 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_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."), 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)'), }, annotations: READ_ONLY_ANNOTATIONS, }, async ({ date_debut, date_fin }) => { try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin })); } 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."), inputSchema: {}, annotations: READ_ONLY_ANNOTATIONS, }, async () => { try { return toolResult(await apiGet('/depots-retraits')); } 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); } }, ); }