MCP Distant

This commit is contained in:
2026-07-15 22:40:52 +02:00
parent 4d8fb9bab8
commit fa318f240c
9 changed files with 534 additions and 149 deletions
+30
View File
@@ -49,6 +49,36 @@ services:
- internal - internal
- backend # réseau Traefik - 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: networks:
internal: # communication interne backend <-> frontend internal: # communication interne backend <-> frontend
driver: bridge driver: bridge
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-debug.log
*.log
README.md
index.js
+15
View File
@@ -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"]
+80 -3
View File
@@ -1,7 +1,7 @@
# crowdlending-mcp-server # crowdlending-mcp-server
Serveur MCP local (stdio) pour le portefeuille de crowdlending. Il expose en Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule
lecture seule les données d'un investisseur (investissements, remboursements, les données d'un investisseur (investissements, remboursements,
dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code, 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` ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1`
du backend. du backend.
@@ -9,6 +9,21 @@ du backend.
Il ne fait aucune écriture : toutes les modifications restent à faire dans Il ne fait aucune écriture : toutes les modifications restent à faire dans
l'app web. 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 ## Prérequis
- Node.js ≥ 18 (fetch natif 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é 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 ». 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 ## Tester sans Claude Desktop
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet 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_get_investissement` | Détail d'un investissement + ses remboursements |
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période | | `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash | | `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`) ## Lire une annonce de projet (`crowdlending_fetch_url`)
+207
View File
@@ -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}`);
});
+5 -141
View File
@@ -21,6 +21,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'; import { z } from 'zod';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability'; 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_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
const API_KEY = process.env.CROWDLENDING_API_KEY; const API_KEY = process.env.CROWDLENDING_API_KEY;
@@ -72,28 +73,6 @@ async function apiGet(path, params) {
return body; 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 ──────────────────────────────────────────────────────── */ /* ── Serveur MCP ──────────────────────────────────────────────────────── */
const server = new McpServer({ const server = new McpServer({
name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''), name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''),
@@ -115,125 +94,10 @@ const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title
const withSource = (description) => const withSource = (description) =>
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`; `${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
server.registerTool( // Les 6 outils de lecture de données (investisseur, dashboard, investissements,
'crowdlending_get_investisseur', // remboursements, dépôts/retraits) sont définis dans tools.js, partagés avec
{ // le serveur HTTP distant — voir ce fichier pour leur documentation.
title: withLabel('Profil investisseur'), registerDataTools(server, { apiGet, withLabel, withSource });
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); }
},
);
/* ── Outil fetch_url (Phase 3) ──────────────────────────────────────────── /* ── Outil fetch_url (Phase 3) ────────────────────────────────────────────
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
+4 -2
View File
@@ -1,15 +1,17 @@
{ {
"name": "crowdlending-mcp-server", "name": "crowdlending-mcp-server",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crowdlending-mcp-server", "name": "crowdlending-mcp-server",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
+5 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "crowdlending-mcp-server", "name": "crowdlending-mcp-server",
"version": "0.1.0", "version": "0.2.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...).", "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", "type": "module",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
@@ -9,6 +9,7 @@
}, },
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"start:http": "node http-server.js",
"inspect": "npx @modelcontextprotocol/inspector node index.js" "inspect": "npx @modelcontextprotocol/inspector node index.js"
}, },
"engines": { "engines": {
@@ -17,6 +18,8 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"zod": "^3.25.0" "zod": "^3.25.0"
} }
+182
View File
@@ -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<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."),
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,
};
}