/** * Outils MCP de lecture de données — partagés entre le serveur local (stdio, * index.js) et le serveur distant (HTTP, http-server.js). * * 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 6 outils de lecture * sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas * diverger entre les deux serveurs — un seul endroit à maintenir. * * `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local * (voir index.js) — l'exposer à des utilisateurs distants tiers via le * serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage. */ import { z } from 'zod'; 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é pour être réutilisé tel quel par le serveur HTTP distant (mêmes * messages d'erreur que le serveur local, pour ne pas dérouter l'agent * selon le transport utilisé). */ export function toolError(e) { return { content: [{ type: 'text', text: `Erreur : ${e.message}` }], isError: true, }; }