📡 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:
Danny Avila 2026-08-15 12:48:23 -04:00 committed by GitHub
parent a2ad0aa0c8
commit eb3b353712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 561 additions and 79 deletions

View file

@ -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),
);

View file

@ -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;

View file

@ -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' } }],