LibreChat/e2e/setup/dynamic-mcp-tools.js
Danny Avila 1bccc2bc18
📡 fix: Refresh MCP Tools After List-Changed Notifications (#14686)
* 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>
2026-08-08 13:50:21 -04:00

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,
};