Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | const express = require('express');
const axios = require('axios');
const PORT = process.env.PORT || 8000;
const CONFIG_SERVICE_URL = process.env.CONFIG_SERVICE_URL || 'http://config-service:8000';
const GATEWAY_URL = process.env.GATEWAY_URL || 'http://api-gateway:8000';
const SUPPORTED_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07', 'DRAFT-2026-v1'];
const SERVER_VERSION = '2025-03-26';
// ============================================================================
// Tool definitions
// ============================================================================
const TOOLS = [
{
name: 'list_knowledge_bases',
description: 'Lister les bases de connaissances accessibles via la clé API',
inputSchema: { type: 'object', properties: {}, '$schema': 'http://json-schema.org/draft-07/schema#' }
},
{
name: 'search_knowledge_base',
description: 'Rechercher dans une base de connaissances',
inputSchema: {
'$schema': 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
kb_id: { type: 'string', description: 'ID de la KB (obtenu via list_knowledge_bases)' },
query: { type: 'string', description: 'Requête de recherche' },
limit: { type: 'number', default: 10 },
search_type: { type: 'string', enum: ['hybrid', 'fulltext', 'semantic'], default: 'hybrid' }
},
required: ['kb_id', 'query']
}
}
];
function gwHeaders(apiKey) {
return { 'x-api-key': apiKey, 'Content-Type': 'application/json' };
}
// ============================================================================
// Tool handlers
// ============================================================================
async function handleListKnowledgeBases(apiKey) {
if (!apiKey) return { content: [{ type: 'text', text: 'Authentification requise. Fournissez x-api-key.' }] };
const r = await axios.get(`${CONFIG_SERVICE_URL}/api-key/lookup`, {
headers: { 'x-api-key': apiKey }, timeout: 5000
});
if (!r.data?.success || !r.data?.data?.isValid) {
return { content: [{ type: 'text', text: 'Clé API invalide.' }] };
}
const orgId = r.data.data.organizationId;
const kbResponse = await axios.get(`${GATEWAY_URL}/api/v1/${orgId}/knowledge-bases`, {
headers: gwHeaders(apiKey), timeout: 10000
});
const kbs = kbResponse.data?.data?.knowledge_bases || [];
if (!kbs.length) {
return { content: [{ type: 'text', text: 'Aucune base de connaissances trouvée pour cette organisation.' }] };
}
const lines = kbs.map(kb =>
`- **${kb.name}** (ID: \`${kb._id}\`)${kb.description ? `: ${kb.description}` : ''}`
).join('\n');
return { content: [{ type: 'text', text: `Bases de connaissances disponibles :\n\n${lines}\n\nUtilisez \`search_knowledge_base\` avec l'ID d'une KB pour la rechercher.` }] };
}
async function handleSearchKnowledgeBase(args, apiKey) {
if (!apiKey) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Authentification requise.' }) }] };
const { kb_id, query, limit, search_type } = args || {};
if (!kb_id || !query) return { content: [{ type: 'text', text: JSON.stringify({ error: 'kb_id et query sont requis.' }) }] };
const r = await axios.post(`${GATEWAY_URL}/api/v1/knowledge-bases/${kb_id}/search/nooloom`,
{ q: query, limit: limit || 10, hybrid: { semanticRatio: 0.7 } },
{ headers: gwHeaders(apiKey), timeout: 20000 }
);
const results = r.data;
if (!results || !results.facts?.length) return { content: [{ type: 'text', text: JSON.stringify([]) }] };
const jsonResult = results.facts.map(f => ({
text: f.text,
score: f.score,
source: f.source || f.url || null,
timestamp: f.timestamp || null
}));
return { content: [{ type: 'text', text: JSON.stringify(jsonResult) }] };
}
// ============================================================================
// JSON-RPC helpers
// ============================================================================
function rpcError(id, code, message) {
return { jsonrpc: '2.0', id, error: { code, message } };
}
function rpcResult(id, result) {
return { jsonrpc: '2.0', id, result };
}
function sseEvent(data) {
return `event: message\ndata: ${JSON.stringify(data)}\n\n`;
}
function acceptsSse(req) {
const accept = req.headers['accept'] || '';
return accept.includes('text/event-stream');
}
// Auth helper
async function validateApiKey(apiKey, logPrefix) {
if (!apiKey) return { valid: false, orgId: null };
try {
const r = await axios.get(`${CONFIG_SERVICE_URL}/api-key/lookup`, {
headers: { 'x-api-key': apiKey }, timeout: 5000
});
if (r.data?.success && r.data?.data?.isValid) {
return { valid: true, orgId: r.data.data.organizationId };
}
} catch (e) {
if (logPrefix) console.error(`${logPrefix} key validation error: ${e.message}`);
}
return { valid: false, orgId: null };
}
// ============================================================================
// Express server
// ============================================================================
async function main() {
const app = express();
app.use(express.json());
app.use(express.text({ type: '*/*' }));
// Middleware to parse JSON from any Content-Type
app.use((req, res, next) => {
if (!req.body || typeof req.body === 'string') {
try { req.body = JSON.parse(req.body || '{}'); } catch { req.body = {}; }
}
next();
});
app.post('/mcp', async (req, res) => {
try {
const { jsonrpc, method, params, id } = req.body;
const apiKey = req.headers['x-api-key'];
const version = req.headers['mcp-protocol-version'] || SERVER_VERSION;
console.log(`[MCP] POST ${method} id=${id} key=${!!apiKey} version=${version}`);
if (jsonrpc !== '2.0' || !method) {
return res.status(400).json(rpcError(id || null, -32600, 'Invalid Request'));
}
// Handle initialize - MCP lifecycle handshake
if (method === 'initialize') {
// Validate API key during handshake
const auth = await validateApiKey(apiKey, '[MCP]');
if (!auth.valid) {
return res.status(401).json(rpcError(id, -32001, 'Invalid API key'));
}
// Negotiate protocol version (use the highest common)
const clientVersion = params?.protocolVersion || version;
const negotiated = SUPPORTED_VERSIONS.includes(clientVersion) ? clientVersion : SERVER_VERSION;
return res.json(rpcResult(id, {
protocolVersion: negotiated,
capabilities: { tools: {} },
serverInfo: { name: 'nooloom-mcp', version: '2.26.0' }
}));
}
// Handle initialized notification (no response expected)
if (method === 'notifications/initialized' || method === 'initialized') {
return res.status(202).end();
}
// Version negotiation for subsequent methods
if (!SUPPORTED_VERSIONS.includes(version) && !version.includes(SERVER_VERSION)) {
const isSupported = SUPPORTED_VERSIONS.some(sv => version.includes(sv) || sv.includes(version));
if (!isSupported && !version.startsWith('DRAFT')) {
return res.status(400).json(rpcError(id, -32000, `Unsupported version. Supported: ${SUPPORTED_VERSIONS.join(', ')}`));
}
}
if (method === 'tools/list') {
if (!apiKey) return res.status(401).json(rpcError(id, -32001, 'x-api-key header required'));
const auth = await validateApiKey(apiKey, '[MCP]');
if (!auth.valid) return res.status(401).json(rpcError(id, -32001, 'Invalid API key'));
return res.json(rpcResult(id, { tools: TOOLS }));
}
if (method === 'tools/call') {
if (!apiKey) return res.status(401).json(rpcError(id, -32001, 'x-api-key header required'));
const { name, arguments: toolArgs } = params || {};
let toolResult;
if (name === 'list_knowledge_bases') {
toolResult = await handleListKnowledgeBases(apiKey);
} else if (name === 'search_knowledge_base') {
toolResult = await handleSearchKnowledgeBase(toolArgs, apiKey);
} else {
toolResult = { content: [{ type: 'text', text: `Tool inconnue : ${name}` }] };
}
const response = rpcResult(id, toolResult);
if (acceptsSse(req)) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.write(sseEvent(response));
res.end();
} else {
res.json(response);
}
return;
}
// Unknown method
res.json(rpcError(id, -32601, `Method not found: ${method}`));
} catch (error) {
console.error('[MCP] Error:', error.message);
const id = req.body?.id || null;
if (!res.headersSent) {
res.status(500).json(rpcError(id, -32603, `Internal error: ${error.message}`));
}
}
});
app.get('/mcp', async (req, res) => {
const apiKey = req.headers['x-api-key'];
const auth = await validateApiKey(apiKey, '[MCP]');
if (!auth.valid) {
return res.status(401).json(rpcError(null, -32001, 'x-api-key header required'));
}
// Keep SSE connection open for server notifications (n8n expects this)
// Prevents reconnect loop
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(':connected\n\n');
const keepAlive = setInterval(() => res.write(':keepalive\n\n'), 30000);
req.on('close', () => { clearInterval(keepAlive); console.log('[MCP] GET SSE closed'); });
});
app.get('/health', (req, res) => res.json({ status: 'healthy' }));
app.listen(PORT, () => console.log(`🚀 MCP Server running on port ${PORT}`));
}
main().catch(console.error);
|