Update MCP server
This commit is contained in:
+154
-12
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* Outils MCP de lecture de données — partagés entre le serveur local (stdio,
|
||||
* index.js) et le serveur distant (HTTP, http-server.js).
|
||||
* 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 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.
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -170,13 +177,148 @@ function toolResult(data) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 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é). */
|
||||
/** 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); }
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user