LibreChat/packages/api
Marco Beretta 57f3cb750b
🐇 perf: Bound MCP Agent Access Scan by MCP Referencing Agents and Share the Read-Through Cache (#15028)
* perf: bound MCP agent access lookups to MCP-referencing agents and share the registry read-through cache via Redis

Resolving MCP server access started from the full set of agents a user
can VIEW: the ACL distinct query materialized every accessible agent id
(400+ in affected deployments) and shipped the whole list back as an
$in filter, even when almost none of those agents reference MCP
servers. The calculation now starts from the agents whose
mcpServerNames are non-empty (covered by the existing
mcpServerNames_1_tenantId_1 index) and bounds the ACL query to those
candidate ids, so cost scales with agents that actually use MCP
servers. When no agent references any server, the agent-side ACL query
is skipped entirely. The same inversion applies to single-server
access checks, which now short-circuit on servers no agent references.

The registry's per-user read-through cache was process-local Keyv, so
every container recomputed access and no invalidation crossed
instances. It is now backed by standardCache (Redis when configured,
in-memory otherwise) with generation-based invalidation that orphans
entries in O(1) instead of scanning the keyspace, plus a process-local
memo to keep repeated per-request reads off the network.

Closes #14016

* style: fix import order in registry read-through cache files

* fix: harden MCP registry cache sharing per review

Encrypt the shared read-through store with encryptV2 so decrypted
oauth/apiKey credentials never leave the process in plaintext, and fail
open to a cache miss when an entry cannot be decoded.

Treat MCP_REGISTRY_CACHE_TTL=0 as a full cache bypass: entries derive
from ACL access, and without a TTL there is no bound on how long a
revoked user could keep receiving a stale map, in Redis or anywhere.

Move the invalidation generation tag to its own no-TTL store so it can
never expire before entries written under it, sweep expired memo
entries at most once per TTL window so one-time users cannot accumulate,
and settle the candidates and principals reads through one Promise.all
so a principals rejection can never surface as unhandled. The public
direct-server ACL read now starts alongside the candidate scan instead
of after it.

* fix: encrypt and harden the per-server MCP read-through cache

Route the per-server read-through cache through the same encrypted,
ttl-gated wrapper as the aggregate map, so decrypted credentials never
reach Redis in either cache and MCP_REGISTRY_CACHE_TTL=0 disables both
rather than storing ACL-derived entries forever. A null envelope keeps
cached-absent lookups distinguishable from misses, preserving the
existing negative-caching contract.

Shared-store failures now degrade instead of rejecting: entry and
generation reads fail open to a miss, writes fall back to the
process-local memo, so a Redis outage costs a recompute rather than
failing MCP requests. get and set recheck the generation after the
store round trip so an invalidation landing mid-flight cannot have its
pre-mutation value memoized for a full TTL window.

* fix: tenant-scope shared MCP cache keys and close read races

Shared read-through entries are now keyed with scopedCacheKey, which
appends the active ALS tenant, because the DB reads behind a miss are
tenant-filtered: without it a public or per-user lookup in one tenant
could satisfy another tenant's lookup from Redis. The single-flight map
partitions the same way.

The direct-server ACL read is chained off the principals promise so a
rejection handler is attached the moment it is created on both the user
and public paths, and all three initial reads settle through one
Promise.all. The generation recheck in the aggregate cache moved after
decode, so an invalidation landing during decryption still cannot pin
its pre-mutation value. The per-server cache exposes a single decoded
getEntry whose hit flag preserves negative caching, and an undecodable
entry deletes itself instead of shadowing the server until expiry.

* fix: miss on unreadable cache generation and parallelize the direct fetch

An unreadable generation tag now surfaces as a miss on get and a
memo-only write on set instead of falling back to generation zero,
which is a real first generation: a transient read failure could
otherwise revive an unexpired pre-invalidation entry for another TTL
window. The direct-server fetch, which only needs the ids resolved by
the first settlement, now runs concurrently with the agent ACL query
instead of after it.

* fix: fence cache fills across invalidations and scope them per tenant

A fill is now fenced by the generation observed at its miss: when a
registry mutation invalidates the cache while the caller is still
reading Mongo/YAML, the completed value is dropped instead of written
under the new generation, so a removed server or revoked ACL cannot be
resurrected for another TTL window by any replica.

The generation tag itself is tenant-scoped, matching the tenant-scoped
entries: a mutation in one tenant now evicts only that tenant's entries
instead of forcing every tenant back through the ACL and config reads.
Genuinely global events (operator config changes, lifecycle resets) use
the new invalidateAllGlobal, which trades a rare namespace scan for
cross-tenant eviction.

* fix: fence error-induced misses and decouple the agent ACL path

Store-read and decode failures now record their miss generation like an
ordinary miss, so a fill that lands after Redis recovers is still fenced
when an invalidation completed during the compute. The agent-side ACL
lookup chains from its actual inputs (candidates and principals) and the
direct-server fetch follows the ids immediately, so neither read waits
behind the other's settlement barrier.

* fix: bound fill fences by settlement and scope eviction per tenant

Miss fences no longer expire with the entry TTL: a fence lives until its
fill settles or a newer miss overwrites it, so a compute slower than one
TTL window is still fenced when an invalidation completed mid-flight.
The per-server cache gained the same protection through invalidation
versions: a fill finishing after its key was deleted, or after a
namespace clear, is dropped instead of repopulating the pre-mutation
value for every replica.

CACHE-tier mutations (YAML/App repository writes, reinspection, stubs)
now invalidate globally, since those entries are shared across tenants:
the per-server namespace clears and the aggregate map takes its global
path, while DB-tier mutations keep the tenant-scoped eviction. Tenant
invalidation also stops clearing the whole process memo: entries are
tagged with the tenant they were memoized under and only the acting
tenant's are evicted.

* fix: fence each cache fill by its own miss and pass the reinspection tier through

A miss now hands back a fill token capturing the generation (or, for
the per-server cache, the invalidation versions) it observed, and the
matching set fences on that token instead of a shared per-key marker.
Concurrent fills for the same key straddling an invalidation therefore
keep distinct fences: the pre-mutation fill is dropped even when a
newer miss for the same key is current, while the newer fill lands.
The shared marker maps are gone, along with their expiry and
overwrite semantics.

reinspectServer also threads its storageLocation into the invalidation
so a DB-backed reinspection takes the tenant-scoped path instead of the
global CACHE-tier eviction.

* fix: address PR review bot findings

chatgpt-codex-connector:
- share generation fences across cache replicas
- fence aggregate single-flight work by generation
- use an indexed MCP agent candidate predicate
2026-08-25 20:10:32 -04:00
..
src 🐇 perf: Bound MCP Agent Access Scan by MCP Referencing Agents and Share the Read-Through Cache (#15028) 2026-08-25 20:10:32 -04:00
types
.gitignore
babel.config.cjs
jest.config.mjs 📦 chore: update sanitize-html to latest (#14573) 2026-08-01 09:35:57 -04:00
jest.setup.cjs
package.json 📦 chore: bump @librechat/agents to v3.7.2 2026-08-25 18:35:21 -04:00
tsconfig-paths-bootstrap.mjs
tsconfig.build.json
tsconfig.json 🥸 chore: Resolve Agents SDK Path Aliases That Masked Backend Types (#15160) 2026-08-24 08:38:30 -04:00
tsconfig.spec.json
tsdown.config.mjs 🫆 chore: Remove Published Credential Defaults (#14680) 2026-08-07 07:25:05 -04:00