MCP Distant
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
README.md
|
||||
index.js
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+4
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user