A resource was private with no way out but the author's own Nextcloud. Share opens reading — open, preview, download — to one person at a time by exact email (no account is ever listed) or to everyone signed in with one switch; what others share appears in your library marked "Shared by …". Writing never travels: modify, re-skin, delete and the share list stay the author's, every write still filtered on user_id, and the read routes go through one reader rule. Rows follow the resource and the person. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
var axios = require('axios');
|
|
var sleep = require('node:timers/promises').setTimeout;
|
|
|
|
var MCP_URLS = buildMcpUrls();
|
|
var _lastGoodMcpUrl = MCP_URLS[0];
|
|
// The server names this tool clinical_semantic_search. The old nc_ alias is
|
|
// gone from both sides; a stale override would silently retrieve nothing, so it
|
|
// stops the app here rather than at the first search.
|
|
var SEARCH_TOOL_NAME = process.env.CLINICAL_ASSISTANT_SEARCH_TOOL || 'clinical_semantic_search';
|
|
if (SEARCH_TOOL_NAME !== 'clinical_semantic_search') {
|
|
throw new Error('CLINICAL_ASSISTANT_SEARCH_TOOL must be clinical_semantic_search (the nc_semantic_search alias was removed)');
|
|
}
|
|
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
|
|
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
|
|
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
|
|
var _mcpSession = null;
|
|
var _mcpSessionPromise = null;
|
|
var _mcpCallQueue = Promise.resolve();
|
|
// How many tool calls may be in flight at once. They used to run one at a
|
|
// time behind a promise chain, so two people asking at the same moment
|
|
// waited for each other; the server handles concurrent requests on one
|
|
// session, so a small bound is enough to stop the queueing without letting a
|
|
// burst pile onto it.
|
|
var MCP_CONCURRENCY = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3);
|
|
var _inFlight = 0;
|
|
var _waiting = [];
|
|
// The session is renewed before it expires, in the background, so no one
|
|
// pays for a cold initialize at the start of their question.
|
|
var _warmTimer = null;
|
|
var _mcpRequestId = 1;
|
|
var MCP_CLEANUP_TIMEOUT_MS = 5000;
|
|
var _closing = false;
|
|
var _closePromise = null;
|
|
var _ownedSessions = new Set();
|
|
var _initializeController = null;
|
|
|
|
function positiveInt(value, fallback) {
|
|
var n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|
|
|
|
// Shared-library sessions can own server-side Nextcloud clients. Explicit
|
|
// teardown releases those resources promptly on replacement or shutdown.
|
|
// Best effort: failed cleanup must not replace a successful result or primary error.
|
|
function endMcpSession(session) {
|
|
if (!session || !session.sessionId) return Promise.resolve();
|
|
if (session.cleanup) return session.cleanup;
|
|
session.cleanup = (async function() {
|
|
var controller = new AbortController();
|
|
var timer;
|
|
try {
|
|
await Promise.race([
|
|
axios.delete(session.mcpUrl || _lastGoodMcpUrl, {
|
|
headers: { 'Accept': 'application/json, text/event-stream', 'mcp-session-id': session.sessionId },
|
|
timeout: MCP_CLEANUP_TIMEOUT_MS,
|
|
signal: controller.signal
|
|
}),
|
|
new Promise(function(resolve) {
|
|
timer = setTimeout(function() { controller.abort(); resolve(); }, MCP_CLEANUP_TIMEOUT_MS);
|
|
})
|
|
]);
|
|
} catch (e) { /* best effort; server expiry covers failed/unknown sessions */ }
|
|
finally { clearTimeout(timer); _ownedSessions.delete(session); }
|
|
})();
|
|
return session.cleanup;
|
|
}
|
|
|
|
function closeMcpSession() {
|
|
if (_closePromise) return _closePromise;
|
|
_closing = true;
|
|
_mcpSession = null;
|
|
if (_initializeController) _initializeController.abort();
|
|
// Late initialize responses are owned and unwound by initializeMcpSession.
|
|
// This is best effort, not a guarantee of remote deletion before process exit.
|
|
var timer;
|
|
var pending = [_mcpSessionPromise, _mcpCallQueue].concat(Array.from(_ownedSessions, endMcpSession));
|
|
_closePromise = Promise.race([
|
|
Promise.allSettled(pending),
|
|
new Promise(function(resolve) { timer = setTimeout(resolve, MCP_CLEANUP_TIMEOUT_MS); })
|
|
]).then(function() {}).finally(function() { clearTimeout(timer); });
|
|
return _closePromise;
|
|
}
|
|
|
|
async function semanticSearch(query, opts) {
|
|
opts = opts || {};
|
|
return callMcpTool(SEARCH_TOOL_NAME, {
|
|
query: query,
|
|
limit: opts.limit,
|
|
doc_types: ['file'],
|
|
score_threshold: 0,
|
|
fusion: 'rrf',
|
|
include_context: opts.includeContext,
|
|
context_chars: opts.contextChars
|
|
});
|
|
}
|
|
|
|
async function indexedTopicSuggestions(limit) {
|
|
return callMcpTool('nc_indexed_topic_suggestions', {
|
|
limit: limit || 12,
|
|
sample_size: 1500,
|
|
doc_type: 'file'
|
|
});
|
|
}
|
|
|
|
function warmMcpSession() {
|
|
if (!_warmTimer && MCP_SESSION_TTL_MS > 0) {
|
|
_warmTimer = setInterval(function() {
|
|
if (_closing) return;
|
|
var soon = _mcpSession && _mcpSession.expiresAt - Date.now() < MCP_SESSION_TTL_MS / 3;
|
|
if (!_mcpSession || soon) {
|
|
if (soon) { var old = _mcpSession; _mcpSession = null; endMcpSession(old); }
|
|
getMcpSession().catch(function() {});
|
|
}
|
|
}, Math.max(30000, Math.floor(MCP_SESSION_TTL_MS / 3)));
|
|
if (_warmTimer.unref) _warmTimer.unref();
|
|
}
|
|
return getMcpSession();
|
|
}
|
|
|
|
function acquireSlot() {
|
|
if (_inFlight < MCP_CONCURRENCY) { _inFlight++; return Promise.resolve(); }
|
|
return new Promise(function(resolve) { _waiting.push(resolve); });
|
|
}
|
|
|
|
function releaseSlot() {
|
|
var next = _waiting.shift();
|
|
if (next) next(); else _inFlight--;
|
|
}
|
|
|
|
async function callMcpTool(name, args) {
|
|
if (_closing) throw new Error('MCP client is closing');
|
|
await acquireSlot();
|
|
var started = Date.now();
|
|
var sessionMs = 0;
|
|
try {
|
|
var t = Date.now();
|
|
await getMcpSession();
|
|
sessionMs = Date.now() - t;
|
|
return await callMcpToolUnlocked(name, args);
|
|
} finally {
|
|
releaseSlot();
|
|
// Where a slow search spent its time: opening a session or running the
|
|
// call. The one question worth answering when "search=6657" shows up.
|
|
console.info('[clinical-assistant] mcp ' + name + ': session=' + sessionMs + 'ms total=' + (Date.now() - started) + 'ms in_flight=' + _inFlight);
|
|
}
|
|
}
|
|
|
|
async function callMcpToolUnlocked(name, args) {
|
|
var session = await getMcpSession();
|
|
var payload = {
|
|
jsonrpc: '2.0',
|
|
id: nextMcpRequestId(),
|
|
method: 'tools/call',
|
|
params: {
|
|
name: name,
|
|
arguments: args
|
|
}
|
|
};
|
|
var search;
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
|
break;
|
|
} catch (e) {
|
|
if (!isInvalidMcpSessionError(e)) throw e;
|
|
if (_mcpSession === session) _mcpSession = null;
|
|
endMcpSession(session);
|
|
if (attempt === 1) throw e;
|
|
session = await getMcpSession();
|
|
payload.id = nextMcpRequestId();
|
|
}
|
|
}
|
|
return search.result || search;
|
|
}
|
|
|
|
function nextMcpRequestId() {
|
|
_mcpRequestId += 1;
|
|
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
|
|
return _mcpRequestId;
|
|
}
|
|
|
|
async function getMcpSession() {
|
|
if (_closing) throw new Error('MCP client is closing');
|
|
var now = Date.now();
|
|
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
|
|
if (_mcpSessionPromise) return _mcpSessionPromise;
|
|
// Discard stale ownership even when the replacement fails.
|
|
var stale = _mcpSession;
|
|
_mcpSession = null;
|
|
if (stale) endMcpSession(stale);
|
|
_mcpSessionPromise = initializeMcpSession().then(function(session) {
|
|
if (_closing) {
|
|
return endMcpSession(session).then(function() { throw new Error('MCP client is closing'); });
|
|
}
|
|
_mcpSession = session;
|
|
return session;
|
|
}).finally(function() {
|
|
_mcpSessionPromise = null;
|
|
});
|
|
return _mcpSessionPromise;
|
|
}
|
|
|
|
async function initializeMcpSession() {
|
|
var owned = null;
|
|
var controller = new AbortController();
|
|
_initializeController = controller;
|
|
var timer = setTimeout(function() { controller.abort(); }, MCP_INITIALIZE_TIMEOUT_MS);
|
|
try {
|
|
var session = await mcpRequest({
|
|
jsonrpc: '2.0',
|
|
id: nextMcpRequestId(),
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
|
}
|
|
}, null, null, function(response, url) {
|
|
var id = response.headers && response.headers['mcp-session-id'];
|
|
if (id) {
|
|
owned = { sessionId: id, mcpUrl: url };
|
|
_ownedSessions.add(owned);
|
|
if (_closing) endMcpSession(owned);
|
|
}
|
|
}, controller.signal);
|
|
if (!owned || !session.result) throw new Error('MCP initialize failed');
|
|
if (_closing) throw new Error('MCP client is closing');
|
|
await mcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
|
|
owned.sessionId, owned.mcpUrl, null, controller.signal);
|
|
if (_closing) throw new Error('MCP client is closing');
|
|
owned.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
|
return owned;
|
|
} catch (e) {
|
|
await endMcpSession(owned);
|
|
throw e;
|
|
} finally { clearTimeout(timer); _initializeController = null; }
|
|
}
|
|
|
|
async function mcpRequest(payload, sessionId, preferredUrl, onResponse, signal) {
|
|
var headers = {
|
|
'Accept': 'application/json, text/event-stream',
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (sessionId) headers['mcp-session-id'] = sessionId;
|
|
// A trickling response must not hold the shared call queue indefinitely.
|
|
signal = signal || AbortSignal.timeout(MCP_REQUEST_TIMEOUT_MS);
|
|
try {
|
|
var resp = await postMcpWithRetry(payload, headers, preferredUrl, onResponse, signal);
|
|
var body = resp.data;
|
|
// Streaming initialize exposes the session header before a body read can fail.
|
|
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
|
var chunks = [];
|
|
for await (var chunk of body) chunks.push(Buffer.from(chunk));
|
|
body = Buffer.concat(chunks).toString('utf8');
|
|
}
|
|
var parsed = parseMcpResponse(body);
|
|
if (parsed.error) throw new Error(parsed.error.message || 'MCP request failed');
|
|
parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null;
|
|
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
|
|
return parsed;
|
|
} catch (e) {
|
|
// Callers log messages: never propagate raw upstream bodies, credentials or IDs.
|
|
var safe = new Error('MCP request failed');
|
|
safe.invalidSession = isInvalidMcpSessionError(e);
|
|
throw safe;
|
|
}
|
|
}
|
|
|
|
async function postMcpWithRetry(payload, headers, preferredUrl, onResponse, signal) {
|
|
var lastErr = null;
|
|
var isNotification = payload && payload.method === 'notifications/initialized';
|
|
if (isNotification && !preferredUrl) throw new Error('MCP notification requires its session endpoint');
|
|
// Session handshake belongs to its initializer, not an alternate server.
|
|
var urls = isNotification ? [preferredUrl] : orderedMcpUrls(preferredUrl);
|
|
var isInitialize = payload && payload.method === 'initialize';
|
|
for (var round = 0; round < 2; round++) {
|
|
for (var i = 0; i < urls.length; i++) {
|
|
if (_closing) throw new Error('MCP client is closing');
|
|
var url = urls[i];
|
|
try {
|
|
var resp = await axios.post(url, payload, {
|
|
headers: headers,
|
|
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
|
|
signal: signal,
|
|
responseType: isInitialize ? 'stream' : 'text',
|
|
transformResponse: [function(data) { return data; }]
|
|
});
|
|
if (onResponse) onResponse(resp, url);
|
|
_lastGoodMcpUrl = url;
|
|
return resp;
|
|
} catch (e) {
|
|
if (onResponse && e.response) {
|
|
onResponse(e.response, url);
|
|
if (e.response.data && typeof e.response.data.destroy === 'function') e.response.data.destroy();
|
|
}
|
|
lastErr = e;
|
|
// Only connection refusal proves initialize could not have created a session.
|
|
if (!isTransientMcpError(e) || (isInitialize && (e.code !== 'ECONNREFUSED' || e.response))) throw e;
|
|
console.warn('[clinical-assistant] MCP endpoint unavailable');
|
|
}
|
|
}
|
|
await sleep(750 * (round + 1), undefined, { signal: signal });
|
|
}
|
|
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;
|
|
}
|
|
|
|
function isInvalidMcpSessionError(e) {
|
|
var status = e && e.response && e.response.status;
|
|
var msg = String(e && e.message || '');
|
|
return !!(e && e.invalidSession) || status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
|
}
|
|
|
|
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 parseMcpResponse(body) {
|
|
var text = String(body || '').trim();
|
|
if (!text) return {};
|
|
if (text.charAt(0) === '{') return JSON.parse(text);
|
|
var lines = text.split(/\r?\n/);
|
|
for (var i = 0; i < lines.length; i++) {
|
|
if (lines[i].indexOf('data:') === 0) {
|
|
return JSON.parse(lines[i].substring(5).trim());
|
|
}
|
|
}
|
|
throw new Error('Unexpected MCP response format');
|
|
}
|
|
|
|
module.exports = {
|
|
semanticSearch: semanticSearch,
|
|
indexedTopicSuggestions: indexedTopicSuggestions,
|
|
getMcpHealth: getMcpHealth,
|
|
warmMcpSession: warmMcpSession,
|
|
closeMcpSession: closeMcpSession
|
|
};
|