mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision (#14858)
* 📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision Shared MCP servers advertised no tools to agents, so every turn failed with "configured to use MCP tools, but none are available" (#14857). `replaceAppServerTools` returned false whenever a publication carried no `publicationRevision`, but only `refreshChangedTools` reserves one. Every other app-level publisher — the first-connect snapshot, reinitialization, on-demand catalog reads, the retained-catalog restore — was silently dropped. The agent path fails closed on that drop: the skipped write returns null, so reinitialize yields no tools and the turn 503s. Startup hid it. `connectAppServers()` defers the initial refresh and calls `refreshToolList()` itself, which does reserve, so a boot that reaches its MCP servers looks healthy. Only a lazily created app connection — the server not yet up when LibreChat boots, a dropped connection, a cold cache — takes the unreserved path. `ConnectionsRepository` now reserves before its own `tools/list`, matching the list_changed path; a failed reservation publishes unordered rather than failing the connection. Publishers with no pre-fetch reservation point have already fetched by the time they reach the cache, so they take the next revision at write time instead of being discarded. `mergeAppTools` still publishes at revision 0 and stays deferential to a live catalog. * 📡 fix: Bind App Catalog Ordering to the Fetch That Produced It Addresses review feedback on the previous commit: allocating a revision at publish time lets a slow `tools/list` of an old catalog outrank a newer one that reserved after it started, and it would let the retained-catalog restore — which republishes deliberately pre-mutation data — outrank a live catalog. Ordering now travels with the data. `fetchToolsSnapshot` reserves before its first page and returns the ticket on the snapshot, so every app-level publisher reads the revision belonging to the read it is publishing rather than one allocated at an unrelated moment. `fetchOrderedToolsSnapshot` carries the refresh's revision when it defers to one, since that is whose catalog it returns. With the reservation at the single point where app-level tools are read, no publisher can forget it, so `replaceAppServerTools` goes back to refusing an unordered write: a publication that lost its ticket fetched at an unknown time and cannot be ordered. A failed reservation is reported as `orderingUnavailable` rather than swallowed, which keeps the list_changed path retrying instead of publishing a catalog that would be silently dropped, and leaves inspection unaffected by a transient cache outage. `MCPServerInspector.getToolFunctions` becomes `getToolCatalog` and returns the revision with the tools, so there is no variant that quietly discards ordering. * 📡 fix: Retry an Empty App Catalog That Could Not Reserve Ordering Review follow-up. The no-tools-capability branch destructured the reservation result and dropped `orderingUnavailable`, publishing without a revision when the revision store was transiently unavailable. That write is rejected in silence, and unlike the snapshot branch this one returned without reaching `refreshToolList()`, so whatever the server last advertised stayed in place until the connection was recreated or the cache expired. Both branches now route an unreservable catalog through the same retry path. * 📡 fix: Serve Tools Whose Shared Catalog Write Could Not Be Ordered Review follow-up. Only the shared catalog write needs ordering; the tools themselves were just read from the server and are correct to serve. Discarding them because the write could not be ordered is what turns a cache failure into a server that appears to have no tools at all, which is the reported symptom. `updateMCPServerTools` now returns the tools it built when the publication has no reserved revision, instead of null. A superseded write still discards — there another replica holds something newer. Reinitialization also asks the connection to republish under backoff when its snapshot could not reserve ordering, so the shared catalog does not stay cold until something else triggers a refresh. * 📡 fix: Surface a Discarded App Catalog Instead of Debug-Logging It #14857 went a release without a diagnostic because the only trace of a dropped app-level catalog was a debug line no deployment runs. Operators saw agents fail every turn with nothing in the logs to explain it, and the reporter had to read the source to find the cause. A publication discarded because it cannot be addressed or ordered means this server's tools are unavailable to every agent that selected them, and serving an unpublished catalog means every request re-fetches it. Both are warnings now. A superseded write stays at debug: concurrent replicas produce it routinely and the winner already holds newer tools. Tests pin the level, so a later refactor cannot quietly make the failure silent again. * 🧪 test: Pin the Reinitialize Path's Catalog Ordering Reinitialization is the path an agent falls back to when the shared catalog is cold, so it is where #14857 surfaced as "configured to use MCP tools, but none are available". Nothing pinned that it forwards the ordering its snapshot was fetched with, nor that it asks the connection to republish a catalog it could not order. Both assertions fail against the pre-fix source.
This commit is contained in:
parent
a2ad0aa0c8
commit
eb3b353712
14 changed files with 561 additions and 79 deletions
|
|
@ -219,13 +219,17 @@ const getMCPTools = async (req, res) => {
|
|||
|
||||
let serverTools;
|
||||
let publicationGeneration;
|
||||
let publicationRevision;
|
||||
try {
|
||||
({ tools: serverTools, publicationGeneration } =
|
||||
await mcpManager.getServerToolFunctionsSnapshot(
|
||||
userId,
|
||||
serverName,
|
||||
mcpConfig[serverName],
|
||||
));
|
||||
({
|
||||
tools: serverTools,
|
||||
publicationGeneration,
|
||||
publicationRevision,
|
||||
} = await mcpManager.getServerToolFunctionsSnapshot(
|
||||
userId,
|
||||
serverName,
|
||||
mcpConfig[serverName],
|
||||
));
|
||||
} catch (error) {
|
||||
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
|
||||
continue;
|
||||
|
|
@ -243,6 +247,7 @@ const getMCPTools = async (req, res) => {
|
|||
serverTools,
|
||||
serverConfig: mcpConfig[serverName],
|
||||
publicationGeneration,
|
||||
publicationRevision,
|
||||
}).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ async function reinitMCPServer({
|
|||
let oauthExpiresAt;
|
||||
let ephemeralServer = false;
|
||||
let publicationGeneration;
|
||||
let publicationRevision;
|
||||
|
||||
try {
|
||||
const registry = getMCPServersRegistry();
|
||||
|
|
@ -279,6 +280,20 @@ async function reinitMCPServer({
|
|||
}
|
||||
if (snapshot.complete) {
|
||||
tools = snapshot.tools;
|
||||
/** Reserved before this snapshot's tools/list; an app-level catalog cannot publish
|
||||
* without it, and allocating a later one here would outrank fresher tools. */
|
||||
publicationRevision = snapshot.publicationRevision;
|
||||
if (snapshot.orderingUnavailable && typeof connection.refreshToolList === 'function') {
|
||||
/** These tools still serve this request; the connection republishes the shared
|
||||
* catalog under backoff rather than leaving it cold until the next reinitialize. */
|
||||
connection
|
||||
.refreshToolList()
|
||||
.catch((err) =>
|
||||
logger.debug(
|
||||
`[MCP Reinitialize] Could not schedule a catalog republish for ${serverName}: ${err?.message ?? String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[MCP Reinitialize] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
|
||||
|
|
@ -306,6 +321,7 @@ async function reinitMCPServer({
|
|||
tools,
|
||||
serverConfig,
|
||||
...(publicationGeneration && { publicationGeneration }),
|
||||
...(publicationRevision && { publicationRevision }),
|
||||
});
|
||||
if (availableTools == null) {
|
||||
tools = null;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,50 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
/** An app-level catalog write is dropped unless it carries the ordering reserved before its
|
||||
* own tools/list. When this path forwarded no revision, every publication was discarded and
|
||||
* agents were told the server had no tools at all (#14857). */
|
||||
it('publishes under the ordering its snapshot was fetched with', async () => {
|
||||
mockGetConnection.mockResolvedValue({
|
||||
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
||||
tools: [{ name: 'search', inputSchema: { type: 'object' } }],
|
||||
complete: true,
|
||||
publicationRevision: '7',
|
||||
}),
|
||||
});
|
||||
|
||||
await reinitMCPServer({
|
||||
user,
|
||||
serverName,
|
||||
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
||||
});
|
||||
|
||||
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serverName, publicationRevision: '7' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the connection to republish a catalog it could not order', async () => {
|
||||
const refreshToolList = jest.fn().mockResolvedValue(undefined);
|
||||
mockGetConnection.mockResolvedValue({
|
||||
refreshToolList,
|
||||
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
||||
tools: [{ name: 'search', inputSchema: { type: 'object' } }],
|
||||
complete: true,
|
||||
orderingUnavailable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await reinitMCPServer({
|
||||
user,
|
||||
serverName,
|
||||
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
||||
});
|
||||
|
||||
expect(refreshToolList).toHaveBeenCalledTimes(1);
|
||||
expect(result.tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves cached tools when live recovery returns an incomplete snapshot', async () => {
|
||||
const fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({
|
||||
tools: [{ name: 'partial', inputSchema: { type: 'object' } }],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue