MCP Distant
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user