From 33ca4e65d9be4ccf58a2b1d63b35f07f7a4dab44 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 6 May 2026 07:42:03 +0200 Subject: [PATCH] fix: harden clinical assistant MCP connectivity --- src/routes/clinicalAssistant.js | 105 +++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index a5a4eb3..76c7fa3 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -16,7 +16,8 @@ var logger = require('../utils/logger'); router.use(authMiddleware); -var MCP_URL = process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || 'http://127.0.0.1:8100/mcp'; +var MCP_URLS = buildMcpUrls(); +var _lastGoodMcpUrl = MCP_URLS[0]; var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.'; var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i; var TOPIC_SUGGESTIONS = { @@ -70,7 +71,15 @@ router.get('/clinical-assistant/status', async function(req, res) { var imageModel = await getSetting('clinical_assistant.image_model', ''); var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8); var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400); - res.json({ success: true, chatModel: chatModel, imageModel: imageModel, searchLimit: searchLimit, contextChars: contextChars, mcpUrl: MCP_URL.replace(/\/mcp$/, '/mcp') }); + var mcpHealth = await getMcpHealth(); + res.json({ + success: true, + chatModel: chatModel, + imageModel: imageModel, + searchLimit: searchLimit, + contextChars: contextChars, + mcp: mcpHealth + }); } catch (e) { res.status(500).json({ error: 'Request failed' }); } @@ -209,28 +218,104 @@ async function semanticSearch(query, opts) { context_chars: opts.contextChars } } - }, session.sessionId); + }, session.sessionId, session.mcpUrl); return search.result || search; } -async function mcpRequest(payload, sessionId) { +async function mcpRequest(payload, sessionId, preferredUrl) { var headers = { 'Accept': 'application/json, text/event-stream', 'Content-Type': 'application/json' }; if (sessionId) headers['mcp-session-id'] = sessionId; - var resp = await axios.post(MCP_URL, payload, { - headers: headers, - timeout: 90000, - responseType: 'text', - transformResponse: [function(data) { return data; }] - }); + var resp = await postMcpWithRetry(payload, headers, preferredUrl); var parsed = parseMcpResponse(resp.data); if (parsed.error) throw new Error(parsed.error.message || JSON.stringify(parsed.error)); parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null; + parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl; return parsed; } +async function postMcpWithRetry(payload, headers, preferredUrl) { + var lastErr = null; + var urls = orderedMcpUrls(preferredUrl); + var isInitialize = payload && payload.method === 'initialize'; + for (var round = 0; round < 2; round++) { + for (var i = 0; i < urls.length; i++) { + var url = urls[i]; + try { + var resp = await axios.post(url, payload, { + headers: headers, + timeout: isInitialize ? 8000 : 90000, + responseType: 'text', + transformResponse: [function(data) { return data; }] + }); + _lastGoodMcpUrl = url; + return resp; + } catch (e) { + lastErr = e; + if (!isTransientMcpError(e)) throw e; + console.warn('[clinical-assistant] MCP endpoint unavailable:', url, e.code || e.message); + } + } + await sleep(750 * (round + 1)); + } + throw lastErr; +} + +function orderedMcpUrls(preferredUrl) { + var candidates = []; + [preferredUrl, _lastGoodMcpUrl].concat(MCP_URLS).forEach(function(url) { + if (url && candidates.indexOf(url) === -1) candidates.push(url); + }); + return candidates; +} + +function isTransientMcpError(e) { + var code = e && (e.code || (e.cause && e.cause.code)) || ''; + var status = e && e.response && e.response.status; + return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504; +} + +async function getMcpHealth() { + var results = []; + var urls = orderedMcpUrls(); + for (var i = 0; i < urls.length; i++) { + var mcpUrl = urls[i]; + var healthUrl = mcpUrl.replace(/\/mcp\/?$/, '/health/live'); + try { + var resp = await axios.get(healthUrl, { timeout: 3000 }); + results.push({ url: mcpUrl, healthUrl: healthUrl, ok: true, status: resp.status, data: resp.data }); + _lastGoodMcpUrl = mcpUrl; + return { ok: true, activeUrl: mcpUrl, checked: results, candidates: MCP_URLS }; + } catch (e) { + results.push({ url: mcpUrl, healthUrl: healthUrl, ok: false, error: e.code || e.message }); + } + } + return { ok: false, activeUrl: _lastGoodMcpUrl || null, checked: results, candidates: MCP_URLS }; +} + +function buildMcpUrls() { + var raw = []; + if (process.env.CLINICAL_ASSISTANT_MCP_URLS) raw = raw.concat(process.env.CLINICAL_ASSISTANT_MCP_URLS.split(',')); + raw.push(process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || ''); + raw.push('http://mcp:8000/mcp'); + raw.push('http://mcp-server-mcp-1:8000/mcp'); + raw.push('http://127.0.0.1:8100/mcp'); + var out = []; + raw.forEach(function(url) { + url = String(url || '').trim(); + if (!url) return; + if (!/\/mcp\/?$/.test(url)) url = url.replace(/\/$/, '') + '/mcp'; + if (out.indexOf(url) === -1) out.push(url); + }); + return out; +} + +function sleep(ms) { + return new Promise(function(resolve) { setTimeout(resolve, ms); }); +} + function parseMcpResponse(body) { var text = String(body || '').trim(); if (!text) return {};