Files

328 lines
14 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 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<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. 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); }
},
);
}