#!/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) : ce fichier expose du * HTTP pur, mais claude_desktop_config.json n'accepte que des entrées * command/args — la connexion passe donc par le pont mcp-remote * (https://github.com/geelen/mcp-remote), voir README.md pour la config * exacte (et le wrapper cmd /c obligatoire sous Windows). */ 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é'}`); /** Préfixe commun à toutes les lignes de log applicatif. */ const LOG_PREFIX = `[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}]`; /** Résumé lisible d'une requête JSON-RPC entrante (nom d'outil pour un * tools/call, méthode sinon), pour le log applicatif — jamais la clé API. */ function describeRequest(body) { if (!body || typeof body !== 'object') return 'requête inconnue'; if (body.method === 'tools/call') return `tools/call ${body.params?.name || '?'}`; return body.method || 'requête inconnue'; } /** 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(`${LOG_PREFIX} session ${sessionId.slice(0, 8)} 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); const label = describeRequest(req.body); const startedAt = Date.now(); await session.transport.handleRequest(req, res, req.body); console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)} — ${label} — ${res.statusCode} (${Date.now() - startedAt}ms)`); 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); const startedAt = Date.now(); await transport.handleRequest(req, res, req.body); console.error(`${LOG_PREFIX} nouvelle session ${transport.sessionId ? transport.sessionId.slice(0, 8) : '?'} — initialize — ${res.statusCode} (${Date.now() - startedAt}ms)`); } catch (e) { console.error(`${LOG_PREFIX} 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(`${LOG_PREFIX} à l'écoute sur le port ${PORT}`); });