From fa318f240c3c088287eea69f52243e4e447e43bd Mon Sep 17 00:00:00 2001 From: Olivier Date: Wed, 15 Jul 2026 22:40:52 +0200 Subject: [PATCH] MCP Distant --- docker-compose.yml | 32 +++++- mcp-server/.dockerignore | 5 + mcp-server/Dockerfile | 15 +++ mcp-server/README.md | 83 +++++++++++++- mcp-server/http-server.js | 207 +++++++++++++++++++++++++++++++++++ mcp-server/index.js | 146 +----------------------- mcp-server/package-lock.json | 6 +- mcp-server/package.json | 7 +- mcp-server/tools.js | 182 ++++++++++++++++++++++++++++++ 9 files changed, 534 insertions(+), 149 deletions(-) create mode 100644 mcp-server/.dockerignore create mode 100644 mcp-server/Dockerfile create mode 100644 mcp-server/http-server.js create mode 100644 mcp-server/tools.js diff --git a/docker-compose.yml b/docker-compose.yml index 798ba73..c77ab35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,37 @@ services: networks: - internal - backend # réseau Traefik - + + # Serveur MCP distant (Phase 5) — accessible publiquement sur un + # sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au + # reste de l'app, ce service est volontairement ouvert à des utilisateurs + # distants qui ne peuvent pas déployer le serveur MCP local. La clé API + # (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE + # barrière d'accès — voir mcp-server/http-server.js. + crowdlending-mcp: + build: + context: ./mcp-server + dockerfile: Dockerfile + container_name: crowdlending-mcp + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 4100 + CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1 + MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net + depends_on: + crowdlending-backend: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.http.routers.crowdlending-mcp.rule=Host(`mcp.crowdlending.croguennec.net`)" + - "traefik.http.routers.crowdlending-mcp.entrypoints=websecure" + - "traefik.http.routers.crowdlending-mcp.tls.certresolver=le" + - "traefik.http.services.crowdlending-mcp.loadbalancer.server.port=4100" + networks: + - internal + - backend # réseau Traefik + networks: internal: # communication interne backend <-> frontend driver: bridge diff --git a/mcp-server/.dockerignore b/mcp-server/.dockerignore new file mode 100644 index 0000000..e3ee72d --- /dev/null +++ b/mcp-server/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +*.log +README.md +index.js diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..95e1aa0 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY tools.js http-server.js ./ + +EXPOSE 4100 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["node", "http-server.js"] diff --git a/mcp-server/README.md b/mcp-server/README.md index 1642bfc..e7b411c 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,7 +1,7 @@ # crowdlending-mcp-server -Serveur MCP local (stdio) pour le portefeuille de crowdlending. Il expose en -lecture seule les données d'un investisseur (investissements, remboursements, +Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule +les données d'un investisseur (investissements, remboursements, dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1` du backend. @@ -9,6 +9,21 @@ du backend. Il ne fait aucune écriture : toutes les modifications restent à faire dans l'app web. +Deux modes, deux fichiers : + +- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude + Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en + local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web). +- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par + l'administrateur de l'instance) sur `mcp.crowdlending.croguennec.net`, + accessible à n'importe quel utilisateur distant sans rien installer : + juste une URL + sa clé API personnelle à coller dans son client MCP. + N'inclut PAS `crowdlending_fetch_url` (voir plus bas). + +Les deux partagent le même code pour les 6 outils de lecture de données +(`tools.js`) — leur comportement ne peut donc pas diverger entre les deux +modes. + ## Prérequis - Node.js ≥ 18 (fetch natif requis) @@ -122,6 +137,68 @@ dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description se termine par la source exacte interrogée — de quoi lever toute ambiguïté si vous demandez « mon encours en prod » vs « mon encours en dev ». +## Serveur distant — pour les utilisateurs sans installation locale + +Si vous n'êtes pas sur la machine qui héberge ce dépôt (pas de Node.js, pas +envie de cloner le repo), vous pouvez vous connecter directement au serveur +MCP distant déployé sur `https://mcp.crowdlending.croguennec.net` — aucune +installation nécessaire, juste votre clé API personnelle. + +**1. Créer votre clé API** — dans l'app, Mon compte → Clés API → Nouvelle +clé (elle ne sera affichée qu'une seule fois, copiez-la). + +**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur → +Serveurs MCP locaux → Modifier la config, puis ajoutez : + +```json +{ + "mcpServers": { + "crowdlending-distant": { + "url": "https://mcp.crowdlending.croguennec.net/mcp", + "headers": { + "X-API-Key": "clk_live_..." + } + } + } +} +``` + +**3. Redémarrez Claude Desktop.** Les 6 outils `crowdlending_*` doivent +apparaître (sans `crowdlending_fetch_url`, réservé au serveur local). + +### Différences avec le serveur local + +- **Pas de `crowdlending_fetch_url`** : lire une page web arbitraire pour un + utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice + réel pour ce cas d'usage. Cet outil reste réservé au serveur local. +- **Authentification par requête, pas par process** : le serveur local a une + clé API fixée une fois pour toutes via une variable d'environnement — un + process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en + parallèle : chaque session est créée à partir de la clé API envoyée dans + l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette + session uniquement. Deux utilisateurs ne partagent jamais de données. +- **Exposé publiquement, sans la liste blanche d'IP** qui protège le reste de + l'app (`ipwhitelist-all`) : la clé API est la seule barrière d'accès. + Traitez-la comme un mot de passe — ne la partagez pas, révoquez-la + immédiatement en cas de doute (Mon compte → Clés API). +- **Limite de débit** : 60 requêtes/minute par adresse IP, au-delà l'API + répond `429`. +- **Sessions** : une session inactive plus de 30 minutes est fermée côté + serveur (mémoire uniquement, aucune persistance) ; votre client MCP en + recréera une automatiquement à la prochaine requête. + +### Déploiement (administrateur de l'instance) + +Le service `crowdlending-mcp` du `docker-compose.yml` construit et lance +`http-server.js`, exposé via Traefik sur `mcp.crowdlending.croguennec.net` +(certificat TLS automatique, même resolver que le reste de l'app). Un +enregistrement DNS pour ce sous-domaine, pointant vers la même IP que +`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement. +Variables d'environnement du service : `CROWDLENDING_API_URL` (URL interne +du backend sur le réseau Docker, déjà configurée) et `MCP_ALLOWED_HOSTS` +(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit +correspondre exactement au(x) nom(s) de domaine exposé(s)). + ## Tester sans Claude Desktop Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet @@ -144,7 +221,7 @@ Tous en lecture seule (`readOnlyHint: true`) : | `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements | | `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période | | `crowdlending_list_depots_retraits` | Historique des mouvements de cash | -| `crowdlending_fetch_url` | Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous | +| `crowdlending_fetch_url` | *(serveur local uniquement)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous | ## Lire une annonce de projet (`crowdlending_fetch_url`) diff --git a/mcp-server/http-server.js b/mcp-server/http-server.js new file mode 100644 index 0000000..3fc4e85 --- /dev/null +++ b/mcp-server/http-server.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** + * Serveur MCP distant (HTTP, Streamable HTTP transport) — Crowdlending Tracker + * + * Variante "réseau" du serveur local stdio (index.js) : mêmes outils de + * lecture (voir tools.js), mais accessible via une URL publique plutôt que + * comme process enfant sur la machine de l'utilisateur. Pensé pour les + * utilisateurs distants qui ne peuvent/veulent pas installer Node.js et + * cloner ce dépôt en local — ils n'ont qu'à ajouter une URL + leur clé API + * personnelle dans la config de leur client MCP (Claude Desktop, etc.). + * + * DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est + * lancé une fois par utilisateur, avec UNE clé API fixée par variable + * d'environnement pour toute la durée du process. Ici, un seul process sert + * potentiellement PLUSIEURS utilisateurs distants en parallèle — il n'y a + * donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée + * à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête + * HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des + * outils de CETTE session uniquement (voir createSession ci-dessous). Deux + * utilisateurs distants ne partagent jamais d'état ni de données. + * + * `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le + * détail) : réservé au serveur local, pour limiter le risque SSRF envers + * des utilisateurs tiers non maîtrisés. + * + * Sécurité réseau : ce service est prévu pour être exposé directement sur + * internet (sous-domaine dédié, sans la liste blanche d'IP qui protège le + * reste de l'app) — la clé API est donc la SEULE barrière. Voir le + * middleware `requireApiKeyHeader` et la validation du Host (protection + * anti DNS-rebinding) plus bas. + */ + +import { randomUUID } from 'node:crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import rateLimit from 'express-rate-limit'; +import { registerDataTools, toolError } from './tools.js'; + +const PORT = Number(process.env.PORT || 4100); +const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, ''); +// Nom(s) d'hôte public(s) attendus dans l'en-tête Host — protection anti +// DNS-rebinding. Séparés par des virgules si plusieurs (ex. dev + prod). +const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net') + .split(',').map((h) => h.trim()).filter(Boolean); +// Durée d'inactivité au-delà de laquelle une session orpheline est fermée +// (client parti sans DELETE explicite — évite une fuite mémoire lente). +const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min + +console.error(`[crowdlending-mcp-remote] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}`); + +/* ── Client API : une closure par session, liée à LA clé API de cette + session (jamais un module-level constant, contrairement à index.js). ── */ +function makeApiGet(apiKey) { + return 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': apiKey, '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; + }; +} + +/* ── Gestion des sessions ─────────────────────────────────────────────── */ +// sessionId -> { transport, apiKey, lastActivity } +const sessions = new Map(); + +function touchSession(sessionId) { + const s = sessions.get(sessionId); + if (s) s.lastActivity = Date.now(); +} + +setInterval(() => { + const now = Date.now(); + for (const [sessionId, s] of sessions.entries()) { + if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) { + console.error(`[crowdlending-mcp-remote] session ${sessionId} inactive depuis plus de 30 min, fermeture.`); + s.transport.close(); + sessions.delete(sessionId); + } + } +}, 5 * 60 * 1000).unref(); + +/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */ +async function createSession(apiKey) { + const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' }); + registerDataTools(server, { + apiGet: makeApiGet(apiKey), + withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`, + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, { transport, apiKey, lastActivity: Date.now() }); + }, + }); + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId); + }; + + await server.connect(transport); + return transport; +} + +/* ── Application Express ──────────────────────────────────────────────── */ +const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS }); + +// Limite basique anti-abus : une clé compromise ou un client buggé ne doit +// pas pouvoir marteler l'API backend sans frein. Comptabilisé par IP (la +// clé API n'est lue qu'après ce middleware). +app.use(rateLimit({ + windowMs: 60 * 1000, + limit: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Trop de requêtes — réessayez dans une minute.' }, +})); + +app.get('/health', (req, res) => res.json({ status: 'ok', sessions: sessions.size })); + +app.post('/mcp', async (req, res) => { + try { + const sessionId = req.headers['mcp-session-id']; + + if (sessionId) { + const session = sessions.get(sessionId); + if (!session) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session inconnue ou expirée' }, id: null }); + return; + } + // Garde-fou : si le client envoie une clé API différente de celle qui a + // créé la session, on refuse plutôt que de silencieusement continuer + // avec l'ancienne clé. + const headerKey = req.headers['x-api-key']; + if (headerKey && headerKey !== session.apiKey) { + res.status(403).json({ jsonrpc: '2.0', error: { code: -32002, message: 'Clé API différente de celle ayant initialisé cette session' }, id: null }); + return; + } + touchSession(sessionId); + await session.transport.handleRequest(req, res, req.body); + return; + } + + if (!isInitializeRequest(req.body)) { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Requête invalide : aucun identifiant de session fourni' }, id: null }); + return; + } + + const apiKey = req.headers['x-api-key']; + if (!apiKey) { + res.status(401).json({ jsonrpc: '2.0', error: { code: -32003, message: "En-tête X-API-Key manquant. Générez une clé dans Mon compte → Clés API." }, id: null }); + return; + } + + const transport = await createSession(apiKey); + await transport.handleRequest(req, res, req.body); + } catch (e) { + console.error('[crowdlending-mcp-remote] erreur /mcp POST :', e); + if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null }); + } +}); + +/** GET (flux SSE de notifications) et DELETE (fin de session) délèguent au + * transport existant, identifié par `mcp-session-id` — jamais de création + * de session sur ces deux méthodes. */ +async function handleExistingSession(req, res) { + const sessionId = req.headers['mcp-session-id']; + const session = sessionId && sessions.get(sessionId); + if (!session) { + res.status(404).json({ error: 'Session inconnue ou expirée' }); + return; + } + touchSession(sessionId); + await session.transport.handleRequest(req, res); +} + +app.get('/mcp', handleExistingSession); +app.delete('/mcp', handleExistingSession); + +app.listen(PORT, '0.0.0.0', () => { + console.error(`[crowdlending-mcp-remote] à l'écoute sur le port ${PORT}`); +}); diff --git a/mcp-server/index.js b/mcp-server/index.js index 1beadb6..dd0c582 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -21,6 +21,7 @@ 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; @@ -72,28 +73,6 @@ async function apiGet(path, params) { return body; } -/** 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 (investissements, - * remboursements, dépôts/retraits) sont donc enveloppés dans { items: [...] }. - * Le texte lisible (`content`), lui, reste le JSON brut tel que renvoyé par - * l'API, tableau ou objet. */ -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. */ -function toolError(e) { - return { - content: [{ type: 'text', text: `Erreur : ${e.message}` }], - isError: true, - }; -} - /* ── Serveur MCP ──────────────────────────────────────────────────────── */ const server = new McpServer({ name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''), @@ -115,125 +94,10 @@ const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title const withSource = (description) => `${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`; -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); } - }, -); +// 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 diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 821de7e..59c6985 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,15 +1,17 @@ { "name": "crowdlending-mcp-server", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crowdlending-mcp-server", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "jsdom": "^29.1.1", "zod": "^3.25.0" }, diff --git a/mcp-server/package.json b/mcp-server/package.json index 99ca9c1..7001d29 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "crowdlending-mcp-server", - "version": "0.1.0", - "description": "Serveur MCP local (stdio) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...).", + "version": "0.2.0", + "description": "Serveur MCP pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Deux modes : local (stdio, index.js) et distant (HTTP, http-server.js).", "type": "module", "main": "index.js", "bin": { @@ -9,6 +9,7 @@ }, "scripts": { "start": "node index.js", + "start:http": "node http-server.js", "inspect": "npx @modelcontextprotocol/inspector node index.js" }, "engines": { @@ -17,6 +18,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "jsdom": "^29.1.1", "zod": "^3.25.0" } diff --git a/mcp-server/tools.js b/mcp-server/tools.js new file mode 100644 index 0000000..a0bad3a --- /dev/null +++ b/mcp-server/tools.js @@ -0,0 +1,182 @@ +/** + * 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, + }; +}