mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
* fix(mcp): handle dynamic tool list changes Co-authored-by: Pascal Garber <pascal@artandcode.studio> * test(mcp): fix CI validation * fix(mcp): keep dynamic tool catalogs live * fix(mcp): harden dynamic catalog lifecycle * test(mcp): use typed startup connection * test(mcp): isolate dynamic e2e fixtures * fix(mcp): refresh tools after reconnect * fix(mcp): close dynamic catalog cache gaps * test(mcp): update OAuth connection mocks * fix(mcp): preserve app snapshot ownership * style(mcp): sort connection imports * fix(mcp): close review race conditions * fix(mcp): preserve cache ownership edges * fix(mcp): harden recovery lifecycle * fix(mcp): guard tool-less app refresh * fix(mcp): fence distributed cache races * fix(mcp): retire stale connection state * fix(mcp): keep tool snapshots authoritative * fix(mcp): fence stale app tool publications * style(mcp): sort repository test imports * test(mcp): mock empty startup publication * fix(mcp): preserve app publication generations * fix(mcp): harden publication recovery races * fix(mcp): address tool catalogs by runtime config * fix(mcp): load scoped catalogs for assistant writes * fix(mcp): harden catalog publication recovery * fix(mcp): serialize forced connection replacement * fix(mcp): serialize ordinary creation with replacements * fix(mcp): harden catalog fallback boundaries * fix(mcp): close lifecycle fencing gaps * fix(mcp): preserve catalog authority on failures * fix(mcp): compensate failed catalog mutations * fix(mcp): fence catalog refresh ordering * style(mcp): sort agent loader imports * fix(mcp): cancel stale connection creation * fix(mcp): fence catalog coordination * fix(mcp): close catalog race windows * fix(mcp): harden cross-pod catalog fencing * fix(mcp): close catalog lifecycle edges * style(mcp): sort assistant imports * fix(mcp): reject stale recovery authority * fix(mcp): restore static catalog on every startup * fix(mcp): order app catalog publications * style(mcp): sort catalog revision imports * fix(mcp): separate catalog allocation and commit fences --------- Co-authored-by: Pascal Garber <pascal@artandcode.studio>
130 lines
3.2 KiB
JavaScript
130 lines
3.2 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const z = require('zod/v4');
|
|
|
|
const DEFAULT_STATE_PATH = path.join('/tmp', 'librechat-e2e-mcp-tool-state.json');
|
|
const POLL_INTERVAL_MS = 50;
|
|
|
|
function getStatePath() {
|
|
return process.env.E2E_MCP_STATE_PATH || DEFAULT_STATE_PATH;
|
|
}
|
|
|
|
function emptyState() {
|
|
return { revision: 0, tool: null };
|
|
}
|
|
|
|
function readState() {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(getStatePath(), 'utf8'));
|
|
if (typeof parsed.revision !== 'number') {
|
|
throw new Error('revision must be a number');
|
|
}
|
|
if (
|
|
parsed.tool !== null &&
|
|
(typeof parsed.tool !== 'object' || typeof parsed.tool.description !== 'string')
|
|
) {
|
|
throw new Error('tool must be null or contain a description');
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
return emptyState();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function writeState(state) {
|
|
const statePath = getStatePath();
|
|
const temporaryPath = `${statePath}.${process.pid}.tmp`;
|
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`);
|
|
fs.renameSync(temporaryPath, statePath);
|
|
}
|
|
|
|
function resetState() {
|
|
writeState(emptyState());
|
|
}
|
|
|
|
function schemaForVersion(schemaVersion) {
|
|
if (schemaVersion === 2) {
|
|
return {
|
|
value: z.string(),
|
|
uppercase: z.boolean().optional(),
|
|
};
|
|
}
|
|
return { value: z.string() };
|
|
}
|
|
|
|
function toolCallback({ value, uppercase = false }) {
|
|
const text = uppercase ? value.toUpperCase() : value;
|
|
return Promise.resolve({ content: [{ type: 'text', text }] });
|
|
}
|
|
|
|
/**
|
|
* Keeps one SDK McpServer's live registry synchronized with the shared e2e state file.
|
|
* registerTool/update/remove intentionally exercise the SDK's real list-changed notifications.
|
|
*/
|
|
function watchDynamicTool(server) {
|
|
let lastRevision = -1;
|
|
let registeredTool;
|
|
|
|
const sync = () => {
|
|
const state = readState();
|
|
if (state.revision === lastRevision) {
|
|
return;
|
|
}
|
|
lastRevision = state.revision;
|
|
|
|
if (state.tool == null) {
|
|
registeredTool?.remove();
|
|
registeredTool = undefined;
|
|
console.error(
|
|
`[dynamic-mcp-tools] applied revision ${state.revision}: removed runtime_probe`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const paramsSchema = schemaForVersion(state.tool.schemaVersion);
|
|
if (registeredTool) {
|
|
registeredTool.update({
|
|
description: state.tool.description,
|
|
paramsSchema,
|
|
callback: toolCallback,
|
|
});
|
|
console.error(
|
|
`[dynamic-mcp-tools] applied revision ${state.revision}: updated runtime_probe`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
registeredTool = server.registerTool(
|
|
'runtime_probe',
|
|
{
|
|
description: state.tool.description,
|
|
inputSchema: paramsSchema,
|
|
},
|
|
toolCallback,
|
|
);
|
|
console.error(`[dynamic-mcp-tools] applied revision ${state.revision}: added runtime_probe`);
|
|
};
|
|
|
|
sync();
|
|
const timer = setInterval(() => {
|
|
try {
|
|
sync();
|
|
} catch (error) {
|
|
console.error('[dynamic-mcp-tools] failed to synchronize tool state', error);
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
|
|
return () => clearInterval(timer);
|
|
}
|
|
|
|
module.exports = {
|
|
emptyState,
|
|
getStatePath,
|
|
resetState,
|
|
watchDynamicTool,
|
|
writeState,
|
|
};
|