225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Serveur MCP (HTTP, Streamable HTTP transport) — Crowdlending Tracker
|
|
*
|
|
* Point d'entrée unique, que ce soit en développement local (`npm run dev`,
|
|
* comme le backend et le frontend) ou déployé à distance en production
|
|
* (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
|
|
* docker-compose). Un seul modèle de transport, un seul fichier à
|
|
* maintenir : les outils eux-mêmes vivent dans tools.js.
|
|
*
|
|
* AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
|
|
* l'ancien serveur stdio. Un même process peut servir plusieurs
|
|
* utilisateurs/sessions en parallèle (typiquement un seul en dev local,
|
|
* potentiellement plusieurs en déploiement distant) — 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, puis cette clé est fermée dans le closure
|
|
* des outils de CETTE session uniquement (voir createSession). Deux sessions
|
|
* ne partagent jamais d'état ni de données.
|
|
*
|
|
* Connexion depuis Claude Desktop (dev comme prod) :
|
|
* { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } }
|
|
* (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la
|
|
* prod). Voir README.md pour le détail.
|
|
*/
|
|
|
|
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, registerFetchUrlTool } from './tools.js';
|
|
|
|
const PORT = Number(process.env.PORT || 4100);
|
|
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
|
|
|
|
// Étiquette facultative pour distinguer plusieurs instances connectées en
|
|
// même temps à Claude Desktop (ex. dev local + prod distante). Affiche
|
|
// "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
|
|
// de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
|
|
const LABEL = (process.env.MCP_LABEL || '').trim();
|
|
|
|
// Désactivé par défaut : cet outil lit une URL arbitraire fournie par
|
|
// l'appelant, sûr pour un usage perso (vous seul avez la clé API et
|
|
// n'atteignez que ce process) mais risqué si le serveur est exposé à des
|
|
// tiers non maîtrisés (SSRF). À activer explicitement pour le développement
|
|
// local — jamais sur le déploiement distant public (voir docker-compose.yml).
|
|
const ENABLE_FETCH_URL = ['1', 'true', 'yes'].includes((process.env.MCP_ENABLE_FETCH_URL || '').toLowerCase());
|
|
|
|
// Nom(s) d'hôte attendus dans l'en-tête Host — protection anti DNS-rebinding.
|
|
// `localhost` est inclus par défaut : nécessaire en développement local
|
|
// (connexion directe à localhost:4100) et pour que le HEALTHCHECK Docker
|
|
// (qui interroge http://localhost:4100/health depuis l'intérieur du
|
|
// container) ne soit pas lui-même bloqué. Sans risque côté externe en
|
|
// production : Traefik ne route vers ce service que les requêtes portant le
|
|
// Host public configuré sur son router.
|
|
const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
|
|
.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${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`);
|
|
|
|
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
|
|
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
|
|
|
|
/** Ajoute la source (URL API + libellé) en fin de description. */
|
|
const withSource = (description) =>
|
|
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
|
|
|
|
/* ── Client API : une closure par session, liée à LA clé API de cette
|
|
session (jamais un module-level constant). ── */
|
|
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${LABEL ? `-${LABEL}` : ''}] 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' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
|
|
registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
|
|
if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
|
|
|
|
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.
|
|
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${LABEL ? `-${LABEL}` : ''}] 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${LABEL ? `-${LABEL}` : ''}] à l'écoute sur le port ${PORT}`);
|
|
});
|