246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Serveur MCP local (stdio) — Crowdlending Tracker
|
|
*
|
|
* Expose en lecture seule le portefeuille de crowdlending (investissements,
|
|
* remboursements, dépôts/retraits, dashboard) à un client MCP (Claude Desktop,
|
|
* Claude Code...) via l'API publique /api/v1 du backend.
|
|
*
|
|
* Authentification : clé API générée dans l'app (Mon compte → Clés API),
|
|
* transmise ici via la variable d'environnement CROWDLENDING_API_KEY.
|
|
* La clé est scopée à un seul investisseur — ce serveur ne voit donc que
|
|
* les données de cet investisseur, jamais l'ensemble du portefeuille famille.
|
|
*
|
|
* IMPORTANT : ce process communique avec le client MCP via stdout (protocole
|
|
* JSON-RPC). Ne jamais utiliser console.log ici — uniquement console.error
|
|
* pour les logs de diagnostic (redirigés vers stderr, invisibles du protocole).
|
|
*/
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import { z } from 'zod';
|
|
import { JSDOM } from 'jsdom';
|
|
import { Readability } from '@mozilla/readability';
|
|
import { registerDataTools, toolError } from './tools.js';
|
|
|
|
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
|
|
const API_KEY = process.env.CROWDLENDING_API_KEY;
|
|
|
|
// Étiquette facultative pour distinguer plusieurs instances connectées en
|
|
// même temps à Claude Desktop (ex. une pour le backend de dev, une pour la
|
|
// prod). Sans elle, on peut toujours faire tourner les deux : Claude Desktop
|
|
// namespace déjà chaque outil par le nom du serveur (clé du bloc mcpServers
|
|
// dans claude_desktop_config.json), donc pas de collision technique. Le
|
|
// LABEL sert surtout à ce que l'agent (et vous) voyiez clairement, dans le
|
|
// titre et la description de chaque outil, quel environnement est visé.
|
|
const LABEL = (process.env.CROWDLENDING_LABEL || '').trim();
|
|
|
|
if (!API_KEY) {
|
|
console.error('ERREUR : la variable d\'environnement CROWDLENDING_API_KEY est requise.');
|
|
console.error('Générez une clé dans l\'app : Mon compte → Clés API.');
|
|
process.exit(1);
|
|
}
|
|
|
|
/* ── Client API partagé ───────────────────────────────────────────────── */
|
|
async function apiGet(path, params) {
|
|
const url = new URL(API_BASE + path);
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
|
}
|
|
}
|
|
|
|
let res;
|
|
try {
|
|
res = await fetch(url, {
|
|
headers: { 'X-API-Key': API_KEY, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
} catch (e) {
|
|
throw new Error(`Impossible de joindre l'API (${API_BASE}) : ${e.message}`);
|
|
}
|
|
|
|
const text = await res.text();
|
|
let body;
|
|
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
|
|
if (!res.ok) {
|
|
const msg = (body && body.error) || res.statusText || 'Requête échouée';
|
|
if (res.status === 401) throw new Error(`Clé API invalide ou révoquée (${msg}). Générez-en une nouvelle dans Mon compte → Clés API.`);
|
|
if (res.status === 404) throw new Error(`Ressource introuvable : ${msg}`);
|
|
throw new Error(`Erreur API (${res.status}) : ${msg}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/* ── Serveur MCP ──────────────────────────────────────────────────────── */
|
|
const server = new McpServer({
|
|
name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''),
|
|
version: '0.1.0',
|
|
});
|
|
|
|
const READ_ONLY_ANNOTATIONS = {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: true,
|
|
};
|
|
|
|
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
|
|
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
|
|
|
|
/** Ajoute la source (URL API + libellé) en fin de description, pour que
|
|
* l'agent distingue sans ambiguïté deux instances connectées en parallèle. */
|
|
const withSource = (description) =>
|
|
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
|
|
|
|
// Les 6 outils de lecture de données (investisseur, dashboard, investissements,
|
|
// remboursements, dépôts/retraits) sont définis dans tools.js, partagés avec
|
|
// le serveur HTTP distant — voir ce fichier pour leur documentation.
|
|
registerDataTools(server, { apiGet, withLabel, withSource });
|
|
|
|
/* ── Outil fetch_url (Phase 3) ────────────────────────────────────────────
|
|
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.1; +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;
|
|
}
|
|
|
|
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); }
|
|
},
|
|
);
|
|
|
|
/* ── Démarrage ────────────────────────────────────────────────────────── */
|
|
async function main() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
console.error(`[crowdlending-mcp-server${LABEL ? `-${LABEL}` : ''}] connecté — API cible : ${API_BASE}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('[crowdlending-mcp-server] erreur fatale :', e);
|
|
process.exit(1);
|
|
});
|