Commit graph

816 commits

Author SHA1 Message Date
Dustin Healy
b24eee648e refactor(mcp): advertise UI capability at host level and fix tool-visibility scoping
Advertise io.modelcontextprotocol/ui unconditionally on every MCP connection instead of gating it on
a per-connection appsEnabled value, and remove the now-unused enableApps plumbing from the
connection factory, inspector, and connection-building call sites. Per SEP-1865 the capability is a
per-session statement of host rendering ability, not a per-user setting; gating it per request on a
shared connection pool was both spec-incorrect and unable to support tenant opt-in. Per-tenant apps
enablement stays enforced downstream (callTool UI-resource attachment plus the app endpoints), which
now works for both opt-in and opt-out.

Replace the exact-match visibility helpers with includes-based semantics: a tool is hidden from the
model when an explicit visibility array omits "model", and denied to app callers when it omits
"app". An empty or future-scoped array such as [] or ["model","internal"] is now handled correctly,
and the both-scopes default applies only when visibility is absent. Rename the cache to reflect that
it holds app-hidden tools.
2026-06-30 17:16:58 -07:00
Dustin Healy
ea5600a4b1 docs(mcp): clarify the UI capability is a host-level, per-session setting
Note at the advertising site that io.modelcontextprotocol/ui tracks the instance-wide apps setting
and per-tenant policy is enforced downstream, so the rationale lives with the code.
2026-06-30 08:48:53 -07:00
Dustin Healy
d87c12b7ec fix(mcp): advertise UI capability at host level and align app-resource handling
Per the MCP Apps spec (SEP-1865) the io.modelcontextprotocol/ui capability is negotiated once per
client-server session and declares the host's rendering ability, not a per-user preference;
per-tenant apps policy belongs in the host's downstream layer. Advertise the capability from the
global apps setting at every connection-building path instead of the per-request resolved value, so
a shared serverName-keyed connection no longer has its capability fixed by whichever scope opened it
first. Per-tenant apps-enabled stays enforced downstream in callTool resource attachment and the app
endpoints.

Classify only text/html;profile=mcp-app resources as app-backed so the bridge metadata matches the
client isMcpAppResource check; a plain text/html ui:// resource renders as a static srcDoc on both
sides instead of carrying dead tool-result metadata.

Redact the tool-result _meta (resultMeta) from ui_resources during share serialization so
model-hidden metadata cannot ride into a public shared transcript.

Resolve app follow-up requests through the originating tool call's connection path rather than
forcing a user-scoped connection, which the app-level guard rejects for shared servers.
2026-06-29 14:43:52 -07:00
Dustin Healy
2905c1563b fix(mcp): honor per-request apps flag in discovery and tighten app resource routing
Use the appsEnabled value resolved by resolveAllowlists in the tool-discovery connection path so
an OAuth/reinitialize fallback discovery does not advertise the UI extension for a user whose
effective config disabled apps.

Constrain simple URI-template expansions to exclude query delimiters so a value like q={q} cannot
match q=foo&admin=true and authorize an undeclared parameter on app resources/read.

Route app requests that carry a config-tier override through a request-scoped connection so iframe
reads and tool calls reach the overridden server instead of the cached base app connection.
2026-06-29 12:09:16 -07:00
Dustin Healy
624a6d8f4b fix(mcp): gate apps per-request on app connections and embedded UI resources
Resolve the allowlist-derived appsEnabled value when creating app-level connections in
ConnectionsRepository so a tenant/role/user override that toggles apps is honored instead of the
boot YAML default.

Gate ui:// resources embedded in tool results on the same per-request setting so a disabled scope
renders them as plain resource text rather than a sandboxed app, resolving appsEnabled lazily only
when a result actually carries a renderable UI resource.

Fail closed in canonicalizeUri when a URI does not stabilize within the decode cap so traversal
encoded more deeply than the cap cannot satisfy a template guard a fully-decoding server resolves
as a parent-directory path.
2026-06-29 11:07:55 -07:00
Dustin Healy
1a70dce24b fix(mcp): advertise apps per-request for user connections and tear down navigated sandbox frames
User connections now advertise the io.modelcontextprotocol/ui capability using the per-request
appsEnabled resolved from resolveAllowlists rather than the static base flag, so a tenant/role/user
override of mcpSettings.apps is honored at capability negotiation.

The sandbox proxy treats inner-frame navigation as a teardown signal: after the initial blob load,
any further load means the allow-scripts app navigated its own frame, so the proxy marks it
navigated, revokes the blob, removes the frame, and gates both forwarding paths on that flag. This
stops proxied host responses from reaching a navigated page and stops a navigated page from relaying
messages to the host.
2026-06-29 10:37:39 -07:00
Dustin Healy
87341c67c0 fix(mcp): carry apps flag through the request resolver and canonicalize resource-read auth
resolveMCPAllowlists now returns appsEnabled from the merged tenant-scoped config, so a
tenant/role/user override of mcpSettings.apps reaches the registry's per-request resolution and
callTool attaches no UI resource for users whose tenant disabled apps.

Authorize app-driven resource reads in the canonical (fully percent-decoded) space the server
resolves and reject any relative path segment, so a percent-encoded traversal such as %2e%2e%2f can
no longer match an advertised template. Exact resources/list matches are unaffected.

Trim narrating comments across the MCP Apps changes so the code is self-documenting.
2026-06-29 00:52:58 -07:00
Dustin Healy
f101d73f72 fix(mcp): resolve apps per request, tighten resource templates, extract app controller
Resolve mcpSettings.apps per request through the tenant-scoped allowlist resolver (inheriting the
YAML base when omitted) and consult it in callTool: when a tenant/role/user has apps disabled, the
tool result is returned with no UI resource attached, so those users no longer get a broken iframe
that the gated app endpoints reject. The OAuth-path connection advertises the resolved value.

Constrain query and query-continuation URI-template operators to their declared variable names
instead of the whole query string, so a template like file://items{?id} no longer authorizes
unrelated query parameters such as ?admin=true. The path-traversal guard still applies.

Move the MCP Apps per-endpoint validation and orchestration into packages/api as TypeScript
service functions (readAppResource, listAppResources, listAppResourceTemplates, callAppTool)
exported from @librechat/api, delegating through a structural manager interface to avoid a circular
import. The /api controllers become thin adapters; resolveAppContext, the sandbox file serve, and
the requireMCPAppsEnabled middleware stay in /api as request-bound glue.
2026-06-28 23:52:37 -07:00
Dustin Healy
eedaf1a054 fix(mcp): tighten MCP Apps read-only views and resource-read authorization
Skip the server HTML fetch for resourceUri-only apps in read-only views (shared/search) and render
an "MCP Apps aren't viewable in shared conversations" placeholder instead of a failing iframe, so a
shared transcript never resolves app HTML from the viewer's MCP server. Inline apps still render.

Invalidate the advertised-resource cache on resources/list_changed by keying it on a new
resourceListVersion, so resources/read authorization tracks live add/remove of server resources
instead of staying fresh until reconnect.

Map RFC 6570 URI template operators to bounded patterns instead of a blanket wildcard, and reject a
template match whose resolved URI contains a path-traversal segment, so a query template such as
file://public{?id} can no longer authorize unrelated reads.

Return 400 rather than 500 for denied resource reads, and isolate invalid per-tool UI metadata so a
single malformed _meta.ui.resourceUri no longer aborts the whole server's tool-cache build.
2026-06-28 22:58:21 -07:00
Dustin Healy
ea75afc99a fix(mcp): harden MCP Apps host security and CJS compatibility
Reimplement the MCP Apps ui-meta helpers (RESOURCE_MIME_TYPE, getToolUiResourceUri,
isToolVisibilityModelOnly, isToolVisibilityAppOnly) in packages/api/src/mcp/apps.ts so
@librechat/api no longer imports the ESM-only @modelcontextprotocol/ext-apps from its CommonJS
build. ext-apps remains a client-only dependency, removing the require(ESM) boundary that throws
ERR_REQUIRE_ESM on Node versions without synchronous require(esm) support.

Add an mcpSettings.apps toggle (enabled unless explicitly false). Thread enableApps through
connection creation so the io.modelcontextprotocol/ui capability is advertised only when apps are
enabled, and gate the resource and app-tool-call routes with a requireMCPAppsEnabled middleware.

Authorize app-driven resources/read against the resources and templates a server advertises, so a
sandboxed app cannot proxy arbitrary uris. ui:// resources stay allowed and the check fails closed.

Render MCP apps in shared and search transcripts display-only by withholding the host-bound bridge
handlers and capabilities in read-only views, so an embedded app cannot call tools or read
resources with the viewer's auth while the stored tool result still renders.
2026-06-28 21:56:28 -07:00
Dustin Healy
20afb27961 fix(mcp): sync api lockfile, scope resource ids, and time out stuck app spinners
Record @modelcontextprotocol/ext-apps in the packages/api peerDependencies entry of the lockfile so a clean npm ci (the Dockerfiles' install path) validates the workspace metadata instead of failing before the build. Regenerated in a linux/amd64 container so platform optional deps are preserved.

Include serverName and toolName in deriveResourceId so two MCP servers that share a ui:// app shell and produce the same result no longer collide on one resourceId in the conversation-wide map and route a marker to the wrong server.

Give marker-rendered apps and carousel cards the same 10s load timeout the tool-call view already has, so an app whose iframe never completes the bridge handshake shows a failure state instead of a permanent loading spinner.
2026-06-25 15:09:39 -07:00
Dustin Healy
acc0befd0b fix(mcp): proxy resource templates and fail closed on app config resolution
The host advertises serverResources, and the ext-apps bridge treats resources/read, resources/list, and resources/templates/list as one proxied set. Only the first two were wired, so an app that sent resources/templates/list received a method-not-found. Register an onlistresourcetemplates handler backed by a new MCPManager.listResourceTemplates and a /api/mcp/resources/templates/list route, mirroring the existing resources/list path. Tool listing is left out deliberately: the App Bridge has no app-to-host tools/list request, and serverTools covers only tool calls.

Make app follow-up requests fail closed when scoped config resolution errors. resolveConfigServers gains an opt-in throwOnError so the app path rejects instead of degrading to an empty set, which previously let a transient failure fall back to the base config for the same server name and proxy the iframe request to the wrong server.
2026-06-25 14:42:14 -07:00
Dustin Healy
f31dacac70 fix(mcp): harden app bridge lifecycle and gate ui:// rendering to HTML
Guard the app bridge against stale-resource races. A resourceId switch or unmount while the iframe is still loading or bridge.connect() is pending now cancels the in-flight handleLoad, removes the load listener, and closes the orphaned bridge, so a single iframe can no longer end up with two bridges issuing duplicate sandbox and tool requests.

Build the sandbox URL from apiBaseUrl() and let new URL() resolve it against the window origin. apiBaseUrl() already reflects an absolute or non-root base href, so the extra origin prefix produced a malformed URL that broke every app iframe.

Emit a renderable ui:// marker only for HTML resources, since MCP Apps defines text/html;profile=mcp-app as the sole renderable type; json and remote-dom payloads fall through to plain resource text instead of a marker the host cannot render. Hide the iframe when an app sends request-teardown so a torn-down app no longer leaves a blank frame mounted.
2026-06-25 13:57:39 -07:00
Dustin Healy
30611cb790 fix(mcp): harden app rendering and tool-cache invalidation
Guard the sandbox-ready handler so the proxy's repeated ready signals fetch and post the resource only once, and reveal apps on the initialized handshake so tools with auto-resize disabled are no longer stuck behind the loading spinner. Broaden the inline srcDoc fallback to any text-bearing resource so non-HTML ui:// markers still render.

Stop advertising an MCP App for tool calls whose config the app routes reject (OBO, Graph-token, and runtime body-placeholder servers), since the iframe could never fetch its HTML or run follow-up calls. Skip caching an empty tools/list result so a transient fetch failure is re-fetched instead of disabling apps until reconnect, and fold a tools/list_changed version counter into the cache freshness stamp so live tool changes invalidate a cache that createdAt alone would miss.
2026-06-25 11:54:17 -07:00
Dustin Healy
4261adcd5d fix(mcp): keep app tool calls alive on progress and always send tool input
Sets resetTimeoutOnProgress on app-initiated tool calls so a long-running tool
that keeps streaming MCP progress is not aborted at the fixed connection timeout,
matching the model-initiated callTool path.

Always sends the tool-input notification before the result, even when the tool
has no arguments. MCP Apps expect ui tool-input exactly once before the result,
so apps that initialize from ontoolinput no longer stay blank for no-argument
tools.
2026-06-25 11:09:43 -07:00
Dustin Healy
ff39323fff fix(mcp): OAuth-aware app connections, list proxy, unique result ids, laid-out iframes
Plumbs OAuth context into app follow-up requests. The app controllers now build a
flowManager and tokenMethods and pass them through readResource, listResources, and
appToolCall to getAppConnection and getConnection, so a cold-recreated connection
(idle timeout, restart, reload) for an OAuth-backed server reuses the user's stored
tokens instead of failing for lack of OAuth context.

Backs the advertised serverResources capability with resource listing. Apps that
feature-detect serverResources can call resources/list, which had no handler. A new
listResources manager method, a POST /api/mcp/resources/list route, and an
onlistresources bridge handler proxy listing the same way reads are proxied.

Makes synthetic and embedded app resource ids unique per result snapshot. The id now
mixes in the tool result content, _meta, and error state alongside the resourceUri,
structuredContent, and arguments, so repeated calls that differ only in those fields
no longer collide and overwrite earlier conversation resources.

Keeps app iframes laid out while waiting for size. The frame is rendered transparent
until a positive size event instead of display:none, with the loading state overlaid,
so an app whose initial auto-resize reports zero is not stuck behind the spinner.
2026-06-25 07:12:46 -07:00
Dustin Healy
251b18b9e9 fix(mcp): resolve config and credential context for app follow-up requests
Plumbs the request-scoped config and user credentials into the app endpoints so
config-sourced servers resolve and credentialed connections work even after the
original tool-call connection is gone. The readResource and app-tool-call
controllers now resolve configServers and the user's customUserVars and pass
them through to getAppConnection, which forwards configServers to
getServerConfig and customUserVars to both the connection factory and the header
refresh. Header refresh now runs for customUserVar configs when the route
supplied those vars, and is still skipped when they are absent so a live
connection's resolved headers are never clobbered with bare placeholders.

Prefers the resource item's own _meta.ui csp and permissions for embedded ui://
resources, falling back to tool-level metadata, so a resource that declares its
own connect or resource domains is not served the default restrictive policy.

Stops caching app-initiated resource reads. The five-minute cache now applies
only to the immutable app HTML fetch; bridge onreadresource calls bypass it so
dynamic resources are not served stale.

Advertises MCP Apps support during MCP initialize. The client now sends the
ext-apps capability (mimeTypes including text/html;profile=mcp-app) so servers
using the getUiCapability graceful-degradation path expose app-enhanced tools
rather than text-only fallbacks.
2026-06-24 08:13:32 -07:00
Dustin Healy
228627750a fix(mcp): bridge inline apps, forward full results, and close review gaps
Bridges inline MCP App HTML. Server-bound resources now always render through
the sandbox bridge rather than a bare srcDoc iframe, and useAppBridge sends the
resource's inline text directly when present instead of a resources/read round
trip, so inline text/html;profile=mcp-app apps complete their App.connect
handshake and receive tool input and results. Bare srcDoc is kept only for
inline HTML with no server binding.

Forwards the complete tool result to apps. A shared buildAppToolResult always
produces a result for app-backed resources so ontoolresult fires even for empty
output, and it carries the tool result _meta the App Bridge forwards via
sendToolResult (the result is a full CallToolResult), which apps use to hydrate
component-only state.

Advertises the message capability. The bridge handles ui/message via onmessage
but omitted the matching host capability, so spec-compliant apps disabled
message actions; it now advertises the text message modality it supports.

Permits app reads of server resources. The resources/read proxy required the
ui:// scheme, which contradicts the serverResources capability the bridge
advertises, so it now accepts any resource URI and leaves authorization to the
MCP server.

Allows WebSocket origins in app CSP. The sandbox host allowlist dropped wss://
endpoints declared in csp.connectDomains; the pattern now permits ws and wss so
apps relying on live updates can connect.

Invalidates app-level tool metadata on reconnect. App-level connections can be
transparently recreated when a server config changes, so cached resourceUri and
visibility are now keyed to the connection that produced them and rebuilt when
it changes.
2026-06-24 00:14:32 -07:00
Dustin Healy
de28930ddf fix(mcp): resolve Codex review on the app-bridge follow-ups
Validates open-link schemes before opening. A sandboxed app could send
ui/open-link with any string; onmessage now opens only http and https URLs and
ignores other schemes and malformed URLs, so apps cannot launch javascript: or
data: targets from the host page.

Decodes blob-backed app resources. resources/read may return HTML as a base64
blob rather than text per the MCP Apps spec, so fetchMCPResourceHtml decodes the
blob when text is absent instead of rendering a blank iframe.

Disambiguates embedded ui:// resource ids by payload. The embedded resource id
was hashed from only the template text or URI, so the same template returned by
multiple calls with different structuredContent collided and the conversation
resource map overwrote earlier entries. The id now mixes in the structured
content and tool arguments, matching the synthetic-resource path.

Allows a dedicated sandbox origin to be framed by the host. The MCP Apps spec
requires the host and sandbox to have different origins for web hosts, but the
sandbox route hardcoded same-origin framing. Framing stays same-origin by
default and an operator can list allowed host origins via
MCP_SANDBOX_FRAME_ANCESTORS for a cross-origin sandbox deployment.
2026-06-23 23:29:16 -07:00
Dustin Healy
b664cce8ca fix(mcp): close follow-up Codex findings on the app-bridge fixes
Preserves resolved auth headers for non-DB servers that declare customUserVars.
getAppConnection re-ran processMCPEnv without customUserVars and overwrote the
headers the original tool call resolved, so iframe-initiated calls sent bare
placeholders. Header refresh now also skips when the config has customUserVars,
leaving the connection's resolved headers intact.

Forwards the tool result to embedded ui:// resources. When a tool returns an
inline ui:// resource item rather than declaring it via resourceUri, the
synthetic path already attached structuredContent and content but the embedded
path did not, so the app bridge initialized without result data. Both paths now
carry it.

Stops reusing UI metadata across request-scoped servers. The resourceUri and
visibility caches keyed on serverName:userId could serve a later request the
first request's metadata when the upstream varies by body context, so
request-scoped servers now build their tool metadata fresh per call.
2026-06-23 21:57:21 -07:00
Dustin Healy
ae0e98e4c9 fix(mcp): address Codex round-3 findings declined in error
Re-verification against source showed five previously declined Codex threads
had merit, so this implements the fixes rather than the incorrect push-backs.

Restores rendering of inline ui:// text resources attached to a tool call.
The selector in ToolCall excluded text-bearing resources and ToolCallInfo no
longer received attachments, so an inline resource vanished unless the model
echoed a marker. MCPAppView now renders every renderable attachment and routes
text resources through its srcDoc branch.

Wires metadata cache invalidation. clearResourceUriCache had no caller, so the
resourceUri, modelOnly and knownToolNames caches were never cleared. Tearing
down a user connection now evicts that server/user cache entry.

Threads the original registry config through the iframe follow-up RPCs.
readResource and appToolCall share a getAppConnection helper that resolves the
server config, hands it to getConnection so config-source servers resolve,
refreshes headers for non-DB-sourced servers, and rejects configs that still
need request body placeholders the app context cannot supply.

Completes the host side of the App Bridge so migrated mcp-ui apps keep working:
advertises serverResources and sets onreadresource for lazy resource loads, and
wires onmessage so ui/message (the standardized replacement for mcp-ui prompt
and intent actions) reaches the conversation.

Honors a dedicated sandbox origin. getMCPSandboxUrl passes the parent origin
to the sandbox, which the proxy reads from the parentOrigin query param so the
handshake targets LibreChat instead of the sandbox's own origin.
2026-06-23 21:42:17 -07:00
Dustin Healy
cc45641d7e fix(mcp): address Codex P1/P2 findings — visibility filter, header clobber, base path, inline text
App-only tools (visibility: ['app']) were not filtered in MCPServerInspector.getToolFunctions,
so initializeMCPs → getAppToolFunctions → mergeAppTools was silently exposing them to the LLM
tool cache at startup, bypassing the filter that updateMCPServerTools correctly applies.
Applied the same visibility guard that updateMCPServerTools uses.

appToolCall was calling processMCPEnv without customUserVars for DB-sourced servers, then
setRequestHeaders — overwriting the connection's already-correctly-resolved headers with
unresolved {{MCP_API_KEY}} placeholders. Skipped the re-resolve for DB-sourced servers
since the connection carries valid headers from the original callTool setup.

callMCPAppTool and readMCPResource used hardcoded /api/... paths without the apiBaseUrl()
prefix; subdirectory deployments would miss those routes. apiBaseUrl was already imported for
getMCPSandboxUrl — extended it to both API calls for consistency.

MCPAppCard (carousel) and MCPAppView (ToolCall) both checked toolName && serverName first
when deciding to use the app bridge, but parsers.ts now sets those fields on all UIResources
including inline ui:// resources with text content. Resources with text were therefore silently
routed through the app bridge instead of being rendered directly as srcDoc iframes.
Added !resource.text / !app.text guard so inline HTML resources take the correct path.
2026-06-23 20:27:30 -07:00
Dustin Healy
d143bb10cc fix(mcp): address Codex P2 findings — resource ID collision, isError flag, sandbox base path
Resource ID for synthetic app UIResources now includes toolArgs in the
hash, so repeated calls to the same tool with different arguments
produce distinct IDs instead of overwriting each other in the
conversation resource map.

isError from MCPToolCallResponse is now stored on UIResource and
forwarded in the toolResult sent to the app via sendToolResult, so
app renderers can distinguish failed tool executions.

getMCPSandboxUrl now prepends apiBaseUrl() so the outer iframe src
resolves correctly in deployments served under a base path.
2026-06-23 19:46:47 -07:00
Dustin Healy
3816864392 fix(mcp): address Codex P1/P2 findings — CSP, permissions, toolArgs propagation
Six findings from the Codex review pass on ac2812ba2:

Apply restrictive default CSP when _meta.ui.csp is omitted: buildCspMeta
now uses an empty object fallback so sandboxed apps without explicit CSP
declarations still get default-src 'none' / connect-src 'none' rather than
running with no Content-Security-Policy at all.

Add media-src to buildCspPolicy: resourceDomains now covers audio and video
loads; omitting it previously caused default-src 'none' to block media even
when the server declared approved CDN domains.

Propagate toolArgs through UIResource so inline \ui{} marker renders call
sendToolInput: callTool passes toolArguments into formatToolContent metadata,
parsers stores it on both explicit and synthetic UIResources, and MCPUIResource
and MCPAppCard now forward it to useAppBridge instead of always passing
undefined.

Update outer iframe allow attribute with resolved permissions from
resources/read: the sandboxready handler now re-applies buildAllowAttribute
with the fetched permissions before sendSandboxResourceReady, so
camera/mic/geo permissions declared only in _meta.ui are not blocked at
the browser permission-policy boundary.

Guard appToolCall against Graph API token placeholder servers: uses
mcpOptionsContainGraphTokenPlaceholder to detect unresolvable
{{LIBRECHAT_GRAPH_ACCESS_TOKEN}} placeholders and throws InvalidRequest
with a clear message, matching the existing OBO guard pattern.

Honor app-reported heights in UIResourceCarousel cards: MCPAppCard now
accepts an onHeightChange callback; UIResourceCarousel tracks per-card
dynamic heights and applies them to the outer card container instead of
the fixed 360px value.
2026-06-23 19:06:40 -07:00
Dustin Healy
90b2d7a1ab fix(mcp): harden sandbox security and fix stale closures, error states
Addresses security and correctness findings from a second review pass.

Sandbox hardening: trustedOrigin is now derived from document.referrer at
startup so notifyReady uses the known parent origin instead of the wildcard
'*'. toDomainList validates every entry against a strict host-pattern regex
before joining, preventing CSP injection via malicious server metadata.
serveMCPSandbox now sets Content-Security-Policy: frame-ancestors 'self' and
X-Frame-Options: SAMEORIGIN so the sandbox proxy cannot be framed by
third-party origins.

Server-side guards: appToolCall now validates that toolName is actually
registered on serverName before forwarding to tools/call. The
knownToolNamesCache is populated alongside modelOnlyToolCache in
populateToolCaches, scoped per user/server key. isModelOnlyTool was inlined
into appToolCall now that the single caching pass populates both sets.
readResource gained the updateUserLastActivity call so resource fetches also
prevent idle timeout. 500 responses now return generic messages; McpError
InvalidRequest (-32600) surfaces as 400 with the message.

Client: useAppBridge uses refs for onSizeChanged, toolArgs, and toolResult so
the stable bridge effect closure always reads current values without
triggering a remount. MCPAppView tracks timedOut separately from loaded so a
bridge failure after 10 s shows an error message instead of a blank iframe.
Added com_ui_mcp_app_failed_to_load translation key. Redundant
as string | undefined casts on toolName removed in ToolCall, MCPUIResource,
and UIResourceCarousel.
2026-06-23 18:35:27 -07:00
Dustin Healy
d65c228cea fix(mcp): address second round of Codex review findings
Fixes 13 correctness issues flagged in the second Codex review pass on the
feat/mcp-apps-support branch.

Core server-side changes: resource URI and model-only-tool caches are now
scoped per user/server key so OAuth and user-sourced servers with differing
tool lists cannot cross-contaminate each other. The model-only visibility check
in appToolCall now blocks iframe-initiated calls to tools declared as
visibility: ['model']. appToolCall also runs processMCPEnv to resolve runtime
env/user vars and set request headers before forwarding to tools/call, and
throws for servers that require per-call OBO token minting (unsupported in this
path). parsers.ts now includes structuredContent in the synthetic resourceId
hash to guarantee uniqueness across repeated same-app calls with different
results, skips the early-return guard when a synthetic app resource is present,
appends the ui{} marker to the synthetic text block, and forwards the raw
content array alongside structuredContent so text/image-only app results are
not silently dropped.

Client-side changes: fetchMCPResourceHtml now returns the full _meta.ui from
the resources/read content item so CSP and permissions come from the canonical
location in the spec rather than the tool descriptor. useAppBridge falls back
to the resource-level values when the read result carries no overrides.
The sandbox retry interval clears when sandbox-resource-ready arrives, fixing
the race where the ready notification arrived before the transport was
connected. The size-change handler in MCPUIResource and UIResourceCarousel now
applies the reported height to the wrapper element, and MCPUIResource's iframe
style uses height: 100% so inline apps are not clipped. The carousel loading
placeholder now uses the localized key. Dockerfile.multi copies the sandbox
from client/dist (the Vite output) rather than the source tree, which is the
only path present in the multi-stage runtime image. baseUriDomains from the
CSP config are now honoured in buildCspPolicy instead of always emitting
base-uri 'self'. serverResources was removed from the AppBridge capabilities
advertisement because no resource handlers are registered on the bridge.
2026-06-23 18:18:51 -07:00
Dustin Healy
4da55e8178 fix(mcp): refresh user activity on app tool calls; scope resource cache by user
appToolCall was missing the updateUserLastActivity call that callTool
includes, so app interactions from an iframe would not reset the idle timer
on user-scoped MCP connections.

The client-side resourceCache in mcpApps.ts was keyed only on
serverName:uri, meaning a second user logging in within the 5-minute TTL
could receive cached HTML from the previous user's session. The key now
includes the userId, threaded from the Recoil user atom via useAppBridge.
2026-06-23 17:07:16 -07:00
Dustin Healy
b1fa8221ef fix(mcp): fix three correctness issues from Codex review
parsers.ts: the early-return guard for empty content was firing before the
synthetic UIResource block, so tools returning only structuredContent (no
content items) never produced an app resource. Now the guard skips the early
return when a synthetic app is expected. Also, the synthetic block was not
appending the \ui{resourceId} marker to the text, leaving the LLM without an
ID to place; fixed by mirroring the marker lines that the non-synthetic
resource handler already emits.

ToolCall / MCPUIResource: "Loading interactive view..." was a hardcoded
string; replaced with com_ui_loading_interactive_view from the localization
layer in both components, per project convention.
2026-06-23 16:39:58 -07:00
Dustin Healy
5e844a614a fix(ci): add @modelcontextprotocol/ext-apps to jest transformIgnorePatterns and fix import sort
The package ships ESM-only; adding it to the babel-jest transform allow list
lets all three jest configs (api, packages/api, and config which inherits api)
process it correctly. Also sort imports in ToolCallInfo.tsx and
MCPUIResourceCarousel.test.tsx which the CI sort-imports check flagged.
2026-06-23 15:46:38 -07:00
Dustin Healy
ec60cd479d feat(mcp-apps): spec compliance pass on AppBridge implementation
Add hostContext (theme/platform/locale/timeZone/displayMode) to AppBridge
constructor, declare serverResources capability, wire requestteardown and
loggingmessage event listeners, fix buildAllowAttribute placement (set as
iframe allow attribute, not sandbox token), add isToolVisibilityAppOnly
inline check in tool cache builder, and remove dead handleUIAction code
that used the old @mcp-ui/client intent/tool/prompt action API.

Also guard MCPUIResource against useOptionalMessagesConversation returning
undefined outside a provider context.
2026-06-23 14:16:10 -07:00
Dustin Healy
f2f3c18ca4 refactor: replace @mcp-ui/client with @modelcontextprotocol/ext-apps/app-bridge
Switch from the community-maintained @mcp-ui/client to the official Anthropic SDK for MCP app rendering.
Introduces useAppBridge hook that drives AppBridge + PostMessageTransport directly, giving us full
control over the spec protocol and proper propagation of _meta.ui.csp and _meta.ui.permissions from
tool definitions to the sandbox iframe.

Removes useMCPAppCallbacks and the AppRenderer dependency; all three render sites (ToolCall, UIResourceCarousel,
MCPUIResource) now use a plain iframe with useAppBridge. The existing mcp-sandbox.html proxy is unchanged.
2026-06-23 13:55:56 -07:00
Dustin Healy
2b88a47460 feat: MCP Apps support (squashed for rebase) 2026-06-21 23:55:17 -07:00
Danny Avila
1505fd5262
📦 chore: Bump @librechat/agents to v3.2.44
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
2026-06-21 08:39:10 -04:00
Danny Avila
5eb1c2c107
🖇️ feat: Reference Selected Chat Text with Multi-Quote Popup (#13868)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🖇️ feat: Reference Selected Chat Text with Multi-Quote Popup

Add a ChatGPT/Codex-style quote feature: selecting text in any message shows
an 'Add to chat' popup that accumulates removable quote chips above the
composer. On submit, the excerpts are merged into the user message text as
Markdown blockquotes (counted in the user message token count, not a system
message) and persisted on the message so they render on the user bubble and
survive reload.

- packages/api: add getReferencedQuotes + mergeQuotedText helpers (blockquote merge, length/count caps) with unit tests
- BaseClient.sendMessage: temporarily merge req.body.quotes into userMessage.text before buildMessages, restore clean text, persist quotes array
- data-schemas + data-provider: add optional quotes field to message schema/type
- client: pendingQuotesByConvoId atom, QuoteButton selection popup, PendingQuoteChips composer row, MessageQuotes persistent display
- useChatFunctions: drain pending quotes onto the message, carry forward on regenerate
- add localization keys and component/integration tests

* 🧪 test: Add Playwright e2e for chat quote feature

Add e2e/specs/mock/quotes.spec.ts covering select -> 'Add to chat' popup ->
chip -> send -> persistent reference block -> reload, plus multi-select
accumulation and chip removal. Selection is driven programmatically (real DOM
Range + dispatched mouseup) to summon the popup deterministically.

Add data-testid hooks (add-to-chat-button, pending-quote-chips, message-quotes)
to the quote components for stable selectors.

* 🛡️ fix: Address Codex review on quote feature

- Run PII filter + OpenAI moderation over req.body.quotes (P1): quoted excerpts
  are merged into the model-facing user message, so they must clear the same
  filters; a crafted quotes payload could otherwise bypass them. Adds tests.
- Carry quotes through edit/save-and-submit replays (overrideQuotes in
  EditMessage), mirroring overrideManualSkills, so edited turns keep context.
- Hide the quote UI for Assistants endpoints (which bypass BaseClient merge),
  so users can't queue quotes the assistant never receives.
- Clear pending quote/skill queues by resolved conversationId in useClearStates,
  not the UI index, so queued-but-unsent selections don't linger in Recoil.
- Cap queued quotes client-side at 10 to match the backend QUOTE_MAX_COUNT, so
  the composer never shows more quotes than are actually sent.

* 🧵 fix: Durably re-merge quotes + Codex round 2

Address Codex's re-review of the quote feature:

- Durable history re-merge (per maintainer decision): quotes are no longer
  merged at request time and stripped; instead each user message's persisted
  message.quotes is merged into its formatted content in AgentClient.buildMessages
  (new prependQuotes helper) for current AND historical turns. The model
  receives the referenced context on every prompt and the token count stays
  consistent with what was persisted; stored text stays clean for display.
- Attach normalized quotes to the user message in handleStartMethods (before
  getReqData/onStart) so the optimistic bubble, resumable abort metadata, and
  saved row all carry them (fixes the abort-metadata gap).
- Skip the quote drain entirely for Assistants endpoints in useChatFunctions,
  leaving the pending atom intact (UI is already hidden there).
- Normalize req.body.quotes via getReferencedQuotes before moderation/PII so
  only the trimmed/truncated/capped excerpts the model will receive are checked.
- Tests: prependQuotes unit tests; BaseClient quote tests assert early
  attachment + clean text; e2e now verifies the model receives the merged
  blockquote on the current turn and re-merged from history on a later turn
  (new E2E_ASSERT_QUOTE mock marker).

* 🔗 fix: Quote share/memo/abort/PII gaps (Codex round 3)

- Shared links: include quotes in the anonymized projection + SharedMessage
  type (+test) so the /share view renders the same reference blocks as the
  owner, mirroring manualSkills/alwaysAppliedSkills.
- MessageRender memo: compare quotes length so a server/resume copy whose only
  change is the quote list re-renders (the block no longer goes stale/missing).
- Resumable job metadata: include quotes in the userMessage written to
  GenerationJobManager so a reload/reconnect mid-stream reconstructs the chips.
- PII + moderation: also scan the merged blockquote+text exactly as the model
  receives it, so a secret split across a quote and the typed body (each clean
  alone) is caught (+cross-boundary test).
- e2e: make quote-add robust against the auto-scroll-dismisses-selection race
  via a retried select+click helper.

* 🛑 fix: Keep quotes on aborted turn's request message (Codex round 4)

abortMiddleware reconstructs finalEvent.requestMessage from jobData.userMessage
but only copied ids + text; include quotes so a stopped quoted turn keeps its
MessageQuotes in the UI and a regenerate-before-reload still sends the
referenced context. Completes the resumable-metadata fix from the prior round.

* 🧮 fix: Quote recount + preliminary abort metadata (Codex round 5)

- Force a canonical token recount for messages carrying quotes in
  AgentClient.buildMessages, so a plain text-only Save edit (which recomputes
  tokenCount from text alone) can't leave a stale, quote-excluding count that
  undercounts context on later turns — recount from the quote-merged copy
  self-heals it.
- Seed normalized quotes into the preliminary userMessage metadata
  (getPreliminaryUserMessage), so an abort during init/tool-loading (before
  onStart) still reconstructs the stopped turn's MessageQuotes.

*  fix: Add getReferencedQuotes to controller test mocks (CI)

request.js's getPreliminaryUserMessage now calls getReferencedQuotes; the
agents controller specs mock @librechat/api wholesale, so the mock must export
it or the call throws and cascades. Added a faithful mock (normalize/cap,
null when empty) to request.resumeMetadata.spec.js and jobReplacement.spec.js.

* 📐 fix: Quotes in context projection + resumable metadata (Codex round 6)

- Context-usage projection (resolveContextProjection): select message.quotes,
  prepend them into the projected user text, and recount quoted messages so the
  context gauge counts the same prompt the model receives (a text-only Save edit
  no longer makes the gauge undercount / over-report remaining budget).
- Resumable job metadata: trackUserMessage (created-event rewrite) and abortJob
  (final requestMessage) now carry quotes; SerializableJobData.userMessage and
  CreatedEvent.message gained an optional quotes field. With the cross-replica
  created-event spread, stopping/reconnecting a quoted turn after the created
  event keeps its MessageQuotes.

* 💬 feat: Collapse multi-select quotes into one chip with hover popup

Composer feedback: the quote chip area now shows a single chip — the excerpt
text for one selection, or a collapsed "{n} selections" pill for multiple,
with a hover popup (HoverCard) listing every excerpt and a per-item remove. The
chip is taller (py-1.5/text-sm) to read less skinny. Adds com_ui_quote_selections
and com_ui_remove_all_quotes; updates unit + e2e tests (e2e drives the count via
a data-quote-count hook and exercises the hover popup).

*  fix: Make multi-selection quote popup keyboard accessible

The collapsed "{n} selections" pill used a HoverCard, which Radix only opens on
pointer hover — its interactive content was unreachable by keyboard. Replaced it
with a Popover: the trigger is a real button that opens on click / Enter / Space
(focus moves into the list, each excerpt's × is tab-navigable, Escape closes and
restores focus), with hover-open preserved for mouse via controlled open state +
a close grace period. Hover-initiated opens skip auto-focus so they don't pull
focus off the composer. Adds an e2e asserting keyboard open/close.

* 📐 fix: Clamp the Add-to-chat button within the viewport (Codex round 7)

The floating selection button positioned via translate(-50%,-100%) (bottom-center
anchor) but clamped top/left as if they were its top-left, so a selection near
the viewport top or sides could render the button partly/fully offscreen. Now it
measures the button (ref + useLayoutEffect) and computes an on-screen top-left —
clamping by the full width within side margins and flipping below the selection
when there's no room above — with no transform, and stays hidden until measured
so it never flashes at an unclamped spot.

* ↩️ fix: Restore pending quotes on early-abort draft (Codex round 8)

When a turn is stopped before the created event (e.g. during tool/MCP init), the
final handler restores requestMessage.text to the draft, but the pending-quote
atom was already drained on submit — so a retry sent no quotes. The abort
requestMessage now carries quotes (preliminary metadata + abort fixes), so the
three early-abort/no-response draft-restore paths in useEventHandlers now also
re-queue pendingQuotesByConvoId from requestMessage.quotes.

*  fix: Use Ariakit Popover for quote selections (keyboard focus)

The multi-selection popup used a hand-rolled Radix Popover with Popover.Anchor +
a manual button, so Radix had no trigger to return focus to — Escape dumped
focus to the page top. Refactored to Ariakit (the codebase's popover primitive,
per DropdownPopup/Fork): the `PopoverDisclosure` is the real trigger, so Escape
closes and returns focus to the composer instead of the top of the page. Keyboard
opens (Enter/Space) autofocus into the list and tab through each excerpt's remove;
hover opens for mouse with autofocus suppressed so it never pulls focus off the
composer. e2e asserts the keyboard open/navigate/Escape flow keeps focus on a
real control (never BODY).
2026-06-21 08:33:11 -04:00
Danny Avila
e515063ffe
🔗 feat: Snapshot Files for Shared-Link Attachments (#13740)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🔗 feat: Snapshot Files for Shared-Link Attachments

Shared-link viewers could read a shared conversation snapshot but not its
attachments: file preview/download still went through the owner-scoped file
ACL (the /api/files router sits behind requireJwtAuth + owner/agent checks),
so anonymous viewers got 401s and authenticated non-owners got 403s — the
repeated `[fileAccess] denied` warnings seen for the preview poller.

Capture an immutable per-share file snapshot (embedded on the SharedLink
document, referencing the original stored object — no byte copy) at share
create/update, and serve those files through new share-scoped routes
authorized by the existing shared-link view permission (public/ACL) plus
snapshot membership, never the owner's live file ACL.

- data-schemas: fileSnapshots on the share doc; capture in create/update;
  read-time rewrite of filepath/preview to /api/share/:id/files/:fileId;
  getSharedLinkFile + lazy backfillSharedLinkFiles for legacy links
- api: GET /api/share/:shareId/files/:file_id[/download|/preview]; route
  context added to fileAccess denial logs
- packages/api: isFileSnapshotEnabled resolver (env + yaml)
- data-provider: interface.sharedLinks.snapshotFiles (default on) + client
  endpoints/services
- client: ShareContext.shareId wired to Image, preview hook, and downloads
- config: SHARED_LINKS_SNAPSHOT_FILES env override (default on)

* 🔒 fix: Address Codex review on shared-link file snapshots

Triage of the Codex review on PR #13740 (2 P1, 7 P2 — all valid):

- P1 (cross-user access): scope the snapshot lookup to the sharing user's own
  files so a message referencing another user's file_id can't widen access.
- P1 (stored XSS): the inline share-file route now serves only safe preview
  types inline (raster images/pdf); everything else is forced to attachment with
  X-Content-Type-Options: nosniff.
- Stream shared downloads by default; redirect to a signed URL only on
  ?direct=true (blob/XHR callers work without bucket CORS).
- Read preview status live from the file record (always current for deferred
  previews) and stop embedding extracted text in the share doc (16MB-limit risk).
- Only lazily backfill when the fileSnapshots field is absent (legacy), not on
  every snapshot miss.
- Backfill legacy shares before rewriting message URLs, and gate URL rewriting
  to public shares so non-public (ACL) shares keep prior behavior (img/anchor
  can't carry the bearer token).
- Frontend: only route a download through the share path when the file was
  actually snapshotted (rewritten href / filepath), else fall back.

* 🔑 feat: Authorize shared-link files for non-public shares via cookie

Extends shared-link file access to non-public (ACL) shares (Codex finding 5).
`<img>`/anchor requests can't carry the bearer access token, so non-public
shares previously 401'd on file loads. Add an optional cookie-auth fallback on
the share file routes that resolves the viewer from the `refreshToken` cookie
(or signed `openid_user_id` cookie) — the same mechanism secure image links use
(validateImageRequest) — then let canAccessSharedLink run the viewer's ACL check.

- new middleware optionalShareFileAuth (+ unit spec); applied to the three
  share file routes after optionalJwtAuth
- URL rewriting in getSharedMessages is no longer gated to public shares (the
  route now authorizes header-less requests), so files work uniformly across
  public and non-public shares; revert the now-unused req.sharePublic plumbing

* 🔒 fix: Second Codex pass on shared-link file snapshots

Addresses the follow-up Codex findings on PR #13740:

- Don't snapshot transient text-source files: FileSources.text filepaths are
  Multer temp paths the upload route deletes, so they can't be streamed —
  removed from the streamable allowlist.
- Unset stale snapshots on a disabled-feature update: updateSharedLink now
  $unsets fileSnapshots when snapshotFiles is false, so an opted-out update
  can't keep serving file ids the update dropped.
- Load tenant config after share resolution: configMiddleware now runs after
  canAccessSharedLink (which enters the share's tenant ALS context), so
  per-tenant interface.sharedLinks.snapshotFiles overrides apply to anonymous
  public views.
- Return a clean 404 when the snapshotted object is gone: resolveShareFile now
  requires the live file record and 404s if it's been deleted/expired, instead
  of letting the stream error after headers are sent (ENOENT / 500).

(The re-flagged P1 about private-viewer rewriting was already fixed in the prior
commit's cookie-auth change.)

* 🔒 fix: Third Codex pass on shared-link file snapshots

Addresses the third Codex review pass on PR #13740:

- P1: keep shared previews/files pinned to the snapshotted version. Snapshot the
  small previewRevision; resolveShareFile 404s when the live file's revision no
  longer matches (file_id reused/overwritten by a later turn), so old links can't
  surface post-share content — covers both preview text and streamed bytes.
- Honor the toggle as a kill switch: resolveShareFile 404s when snapshotFiles is
  disabled, instead of only skipping backfill, so disabling stops serving
  already-snapshotted file URLs.
- Lazy-sweep orphaned 'pending' previews to 'failed' in the share preview route
  (mirrors the owner route) so the client poller reaches a terminal state.
- Resolve the cookie-fallback user in runAsSystem so strict tenant isolation
  doesn't throw before canAccessSharedLink establishes the share tenant context.

*  feat: Per-link "share files" checkbox for shared links

Add a checkbox to the share-link dialog (checked by default) letting the user
choose whether to include the conversation's files in the shared link, with
copy explaining images/files won't be visible to viewers otherwise. Opting out
skips snapshot creation/serving for that link.

- client: ShareButton renders the checkbox gated on the new
  startupConfig.sharedLinksSnapshotFilesEnabled flag; state threads through
  SharedLinkButton into the create/update mutations as `snapshotFiles`.
- data-provider: createSharedLink/updateSharedLink send `snapshotFiles` in the
  body; TStartupConfig gains `sharedLinksSnapshotFilesEnabled`.
- api: POST/PATCH /api/share compute snapshotFiles as
  isFileSnapshotEnabled(req.config) && body.snapshotFiles !== false (admin gate
  AND per-link opt-out); config.js exposes the effective enabled flag to clients.
- en locale: com_ui_share_files (+ _description).

* 🐛 fix: Make the "share files" opt-out actually hide files

Unchecking "share files" at creation didn't hide anything: the shared message
JSON still carried each file's original (e.g. static-served) path, and because
opting out only meant "no fileSnapshots field" — indistinguishable from a legacy
link — getSharedMessages would backfill snapshots on first view whenever the
admin feature was on, re-enabling files entirely.

Fix by persisting and honoring the per-link choice:
- Store `snapshotFiles` (boolean) on the SharedLink so opt-out is distinct from a
  legacy link; set it on create and update.
- getSharedMessages computes includeFiles = adminEnabled && link not opted out;
  when excluded it strips files/attachments from the payload (no original-path
  leak) and never backfills the opted-out link.
- Surface the stored choice via getSharedLink so the dialog checkbox reflects an
  existing link's actual setting instead of always defaulting to checked.

Note: changing the checkbox on an already-created link still applies only when
the link is refreshed (which regenerates the URL) — a UX follow-up.

* 🔒 fix: Close remaining shared-link file opt-out leaks (Codex)

Follow-up to the per-link opt-out, addressing the third Codex pass:

- Honor the opt-out on the file route too: getSharedLinkFile now returns the
  link's `optedOut` choice; resolveShareFile 404s (and never backfills) an
  opted-out link, so a direct /files/:id request can't re-create snapshots.
- Make read/serve viewer-independent: the gate no longer uses the viewer's
  resolved config (isFileSnapshotEnabled(req.config)) — it uses the link's stored
  choice plus a global env-only kill switch (isFileSnapshotKillSwitchActive). A
  viewer's own interface.sharedLinks.snapshotFiles can no longer hide a link's
  files. Create/update still use the creator's config to set the per-link choice.
- Neutralize render URLs for non-snapshotted files: applyShareFileRoute now
  strips filepath/preview for any file/attachment not in the snapshot, so the
  owner's original (e.g. static) path can't be loaded through the share.

* 🔒 fix: Harden shared-file version pinning and local path handling (Codex)

- Refuse reused/overwritten file snapshots more broadly: resolveShareFile now
  refuses to serve when either previewRevision OR `bytes` changed vs the
  snapshot. `bytes` catches non-office reused outputs (e.g. code-exec
  same-filename images that lack previewRevision) and is stable across S3 URL
  refresh and the pending->ready transition. Same-size content swaps remain a
  best-effort gap inherent to the no-byte-copy design.
- Strip cache-busting query strings before local streaming: code-output images
  add `?v=...` to filepath; the share route now splits it off so getLocalFileStream
  resolves the real filename instead of a literal `*.png?v=...` path.

* 💬 fix: Clarify that file-sharing changes apply on link refresh

For an already-created shared link, changing the "share files" checkbox only
takes effect when the link is refreshed (which regenerates the snapshot). Add a
note under the checkbox, shown only when a link already exists, so the behavior
isn't surprising: "Refresh the link to apply this change — files are snapshotted
when the link is refreshed."
2026-06-20 23:05:13 -04:00
Tómas Pálsson
229c54c843
🪢 fix: Paginate MCP tools/list to Load All Tools (#13840)
* 🪢 fix: Paginate MCP tools/list to load all tools

MCP `tools/list` is cursor-paginated, but LibreChat only ever read the
first page. `MCPConnection.fetchTools()` called `client.listTools()` once
and discarded `nextCursor`, and `MCPServerInspector` — which builds the
agent-facing tool registry at startup and per request — called the raw
`client.listTools()` directly. Servers that paginate (e.g. an aggregating
gateway exposing hundreds of tools) only ever exposed page one; tools on
later pages were never registered, and invoking one returned
"This tool's MCP server is temporarily unavailable."

- `MCPConnection.fetchTools()` now follows `nextCursor` across pages and
  concatenates every page's tools, bounded by a configurable page cap
  (`MCP_TOOLS_LIST_MAX_PAGES`, default 50) and a repeated-cursor guard so a
  misbehaving server cannot loop forever. Tools already fetched are returned
  if a later page fails, and the no-throw error contract is unchanged.
- `MCPServerInspector.getToolFunctions()` and `fetchServerCapabilities()`
  now route through `fetchTools()`, so the canonical startup and
  per-request tool registry is fully paginated too.

* style: Sort MCP test imports

* style: Sort mutation type imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-20 11:04:06 -04:00
Danny Avila
59637e136f
📦 chore: Bump @librechat/agents to v3.2.43 (#13854)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
2026-06-19 16:20:27 -04:00
Danny Avila
8969034ad1
fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools (#13850)
*  fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools

Gemini's FunctionDeclaration.parameters schema rejects more JSON Schema
keywords than sanitizeGeminiSchema previously stripped. MCP tools shipping
examples/readOnly/multipleOf/uniqueItems/prefixItems/etc. still 400 with
`Unknown name "<key>"`, the same class as #13623 (exclusiveMinimum).

Verified live against gemini-2.5-flash and gemini-3.5-flash: each added
keyword is rejected through `parameters`, and @langchain/google-genai only
removes additionalProperties/$schema, so they must be stripped here.

*  refactor: Make Gemini strip-list fully live-verified; preserve `default`

Probed every candidate keyword against both the live Gemini API
(gemini-2.5-flash, gemini-3.5-flash) and Vertex AI. Confirmed the inferred
siblings (dependencies/dependentSchemas/contentSchema) are rejected, so they
stay. Dropped `default`: it is part of Gemini's Schema and is accepted by both
the Gemini API and Vertex (no documented reason for its removal in #13623), so
it is now preserved instead of stripped.

*  fix: Preserve `default` data and synthesize array `items` (Codex P2s)

Addresses two Codex findings on the strip-list rework:

- `default` is now copied verbatim instead of recursed, so object/array default
  values (e.g. `{ id: 'abc', readOnly: true }`) keep ordinary data keys that the
  schema-recursion would otherwise strip.
- `prefixItems` is dropped but its first member is synthesized into `items`, since
  Gemini's API requires `items` on every array (live: itemless array => 400; the
  synthesized `{type:array, items:{...}}` => 200 on Gemini 2.5/3.5 and Vertex).

Third finding (patternProperties -> empty object) not actioned: live probing shows
`{type:'object'}` with no properties is accepted by both the Gemini API and Vertex.

*  fix: Treat boolean/tuple array `items` as missing (Codex P2)

The Draft 2020 tuple form `prefixItems: [...], items: false` slipped through: the
`'items' in collapsed` check treated boolean `false` as a real item schema, so no
fallback was synthesized and `items: false` was emitted — which Gemini rejects
(live: `items: false`/`true` => 400 "Invalid value").

Now `items` is only kept when it is a schema object; boolean and tuple-array
(`items: [...]`) forms are dropped, a `prefixItems` member is synthesized when
present, and any array still missing `items` falls back to `{}` (verified accepted
by the Gemini API and Vertex). Adds an `isObjectSchema` guard + tests.
2026-06-19 13:14:47 -04:00
Danny Avila
91f25b8302
📦 chore: bump @librechat/agents to v3.2.42 (#13848)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* 🔧 chore: Update dependencies in package-lock.json and package.json

Bump `form-data` to version 4.0.6 and update `hasown` and `mime-types` dependencies in package-lock.json. Add an `overrides` section in package.json to ensure compatibility with the new `form-data` version.

* 📦 chore: Bump `@librechat/agents` to v3.2.42
2026-06-19 09:47:46 -04:00
Danny Avila
268fcbb78d
🕐 feat: Add promptCacheTtl model parameter for 1h/5m cache duration (#13835)
Some checks failed
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
* 🕐 feat: Add promptCacheTtl model parameter for 1h/5m cache duration

Adds a user-configurable `promptCacheTtl` parameter (dropdown: 5m | 1h)
alongside the existing `promptCache` toggle for Anthropic, Bedrock, and
OpenRouter endpoints. Default is undefined so the agents SDK applies its
own default (1h), letting users opt down to the legacy 5m TTL.

- data-provider: schema, parameterSettings dropdown, types, bedrock picks
- data-schemas: convo/preset types + mongoose defaults
- api: thread promptCacheTtl into anthropic + openai(OpenRouter) llmConfig
- i18n: en translation keys for label/description/default placeholder
- tests: anthropic llm.spec coverage for set + unset cases

* 🔧 fix: Tie Bedrock promptCacheTtl to promptCache + thread OpenRouter TTL params (Codex review)

- bedrock.ts: clear promptCacheTtl whenever promptCache is off/unsupported,
  so an unsupported 1h is never sent on a non-caching Bedrock request
- openai/llm.ts: resolve promptCacheTtl through the same defaultParams/
  addParams/dropParams machinery as promptCache (via promptCacheTtlValue)
  so OpenRouter custom endpoints can configure/override/drop it
- tests: bedrock TTL-tied-to-promptCache cases; OpenRouter TTL default/add/drop

* 🎨 style: Sort imports in openai/llm.spec.ts (CI sort-imports)

*  test: Prove OpenRouter TTL-only selection honors promptCache default (Codex review)

OPENROUTER_DEFAULT_PARAMS injects promptCache:true into defaultParams, so a
TTL-only dropdown selection (promptCacheTtl set, promptCache switch untouched)
still resolves caching on and forwards the TTL. Add regression tests via the
real getOpenAIConfig entry point: TTL-only -> promptCache+TTL both set;
explicit promptCache:false -> both dropped.

* 🔖 chore: Bump librechat-data-provider to 0.8.506

* 🔧 fix: Drop Anthropic promptCacheTtl when promptCache is dropped (Codex review)

dropParams: ['promptCache'] deleted requestOptions.promptCache but left
promptCacheTtl behind, so the admin opt-out path could still carry a TTL
on a request with caching disabled. Clear the TTL alongside promptCache.
2026-06-18 16:36:43 -04:00
Dustin Healy
6a63531eb4
📒 feat: Audit Log Backend for SystemGrant Assign and Revoke Events (#13087)
* 🛡️ feat: Audit log backend for SystemGrants changes

Add an AuditLog Mongoose collection that records every grant assign/revoke as an append-only entry capturing the actor, target principal, capability, timestamp, and tenant scope. Wire the entry-write into the existing admin assignGrant and revokeGrant handlers so the admin panel's audit-log tab populates as grants happen.

The data-schemas package gains the IAuditLog type, a Mongoose schema with tenant + target compound indexes for keyset pagination, a model factory wired through createModels, and an AuditLog methods factory exposing recordAuditEntry, listAuditLogPage (cursor-paginated, faceted, search-aware), findAuditLogEntry, and streamAuditLogEntries.

The packages/api admin layer adds createAdminAuditLogHandlers with three handlers backing the routes the admin panel already consumes: GET /api/admin/audit-log returns paginated entries, GET /api/admin/audit-log/:id returns a single entry for the permalink drawer, and GET /api/admin/audit-log/export.csv streams CSV with formula-injection defang plus UTF-8 BOM.

The Express layer mounts the new router at /api/admin/audit-log behind requireJwtAuth and the ACCESS_ADMIN capability, matching the existing admin route pattern. The audit emission failure is logged via logger.error but never rolls back the grant.

* 🧹 chore: Audit log backend cleanup — offset pagination, name-based filters, type tightening

Switch listAuditLogPage from cursor-based to offset-based pagination with skip().limit() + parallel countDocuments, returning { entries, total } instead of { entries, nextCursor }; the cursor encode and decode helpers are no longer needed and have been removed.

Interpret the actorId and targetPrincipalId filter parameters as case-insensitive partial regex against the denormalized actorName and targetName fields rather than exact-match against the underlying ObjectId. Admin panel users naturally filter by human name, not by Mongo identifier.

Replace the broad Record<string, unknown> casts on req.query with a typed AuditLogQuery shape, drop two unused exported types and the now-unused mongoose Types import, and fix the streamAuditLogEntries Omit literal to match the interface and the offset-based design.

* 🛠️ fix: Address audit log review feedback (CI typecheck, ISO offsets, no-op revoke, deps surface, schema, backpressure, tests)

Resolve the duplicate AuditAction export that broke the data-schemas TypeScript check by importing the canonical declaration from types/admin instead of re-declaring it in types/auditLog.

Accept timezone-offset ISO 8601 timestamps such as 2026-05-01T09:30:00+02:00 in the from and to filter params and reject local-time strings without a zone so every request resolves to an unambiguous instant.

Skip the audit emission on no-op revokes: revokeCapability now returns deletedCount so the admin handler can omit the grant_removed entry when the target grant did not exist, keeping the audit trail factually accurate. Mocks in the existing grants.spec.ts updated to the new return shape.

Drop the required recordAuditEntry from AdminAuditLogDeps since the audit-log handler factory never consumes it; the grants handler factory keeps its optional dep for the write path.

Tighten the tenantId validator on the audit log schema to require a non-empty trimmed string, and rewrite the listing-index comment to describe deterministic offset sort instead of keyset pagination.

Stream the CSV export with explicit backpressure (await drain when res.write returns false) and abort on client disconnect so a cancelled download no longer pins a Mongo cursor or buffers unbounded data in memory.

Add packages/data-schemas/src/methods/auditLog.spec.ts covering tenant and platform scoping, single and multi action filtering, partial-name filtering for actor and capability, the createdAt window, offset pagination with total, ObjectId and date stringification on the wire, regex-metacharacter escape, and streaming completeness.

* 🛠️ fix: Address P1 audit-log review findings (cursor cancel, drain race, filter naming, type dedupe, tenant scope, log enrichment)

The CSV stream handler kept draining Mongo batches after the client
disconnected because the `for await` loop only honored its abort flag
inside `onEntry`. Thread an `isCancelled` callback into
`streamAuditLogEntries` so the methods layer closes the cursor as soon
as the handler sees `close`/`aborted`; a `finally` block guarantees
release on throw. The drain promise in `writeChunk` now races against
the response's `close` event so a destroyed socket cannot strand the
handler on a `drain` that will never fire.

The HTTP filter keys `actorId` and `targetPrincipalId` always did
case-insensitive substring matches on the denormalized `actorName` /
`targetName` columns, never on ObjectIds — a client passing a real id
silently got zero rows. Renamed the wire-level keys to `actorQuery` /
`targetQuery` (matching what the matcher actually does) and kept the
old names as deprecated aliases for one release so the sibling
admin-panel PR can migrate without breaking; each legacy use logs a
deprecation warning. Renamed the corresponding fields in
`AuditLogFilters` too.

`AdminAuditLogEntryWire` duplicated `AdminAuditLogEntry` from
`types/admin.ts` field-for-field, violating the no-duplicate-types
rule. Deleted the duplicate, hoisted `AuditLogPage`,
`RecordAuditEntryInput`, and `AuditLogFilters` from
`methods/auditLog.ts` into `types/auditLog.ts`, and updated the
handler, method factory, and re-exports accordingly.

`tenantFilter` treated `''` as a valid tenant scope, producing a
`{ tenantId: '' }` query that silently returned nothing while the
schema validator rejected `''` on writes. Switched to a strict
`typeof tenantId === 'string' && tenantId.trim().length > 0` check so
reads agree with writes, with new spec coverage for empty and
whitespace-only inputs.

Audit-write failures now log the full forensic payload (action,
capability, tenantId, actorId, target metadata) inside a single meta
object so winston's standard signature surfaces it correctly; a comment
on the catch block explains why the failure mode stays silent (it must
never block a privileged operation).

Stronger filter parsing: invalid `action` values and unknown
`targetPrincipalType` now return 400 instead of silently dropping.
Extracted `MAX_LIMIT` to a constant. Replaced the
`Record<string, Date>` cast in `buildFilter` with a typed local.
Switched the stream cursor to `lean<IAuditLog[]>()` and removed the
`as IAuditLog` cast inside the loop.

*  test: Cover admin audit-log handler with unit tests for auth, validation, tenant isolation, CSV output, and abort

The sibling admin handlers (grants, groups, roles, users) all have
handler specs; this one was missing. The new suite covers 401 on a
missing `req.user`, 400 on malformed ISO `from` / `to`, 400 on
limit > 500, 400 on negative offset, 400 on an unknown action or
`targetPrincipalType`, 400 on a non-ObjectId `:id`, 404 when the
methods layer returns null, that the caller's `tenantId` (not a
forged query-string `tenantId`) is the one passed to the methods
layer, that `actorQuery` / `targetQuery` round-trip, that the
deprecated `actorId` / `targetPrincipalId` aliases still map through,
that the CSV stream emits the BOM as the first chunk with CRLF line
endings and the expected header labels, that quotes, commas, and
newlines are properly escaped, that the formula-injection prefixes
(`=` `+` `-` `@` tab CR) are defanged, that an `isCancelled` callback
reaches the methods layer and flips to true on client `close`, and
that `res.end` is skipped when the client disconnected mid-stream.

* 🛡️ feat: Enforce append-only AuditLog at the schema level

Every field is now marked `immutable: true`, and pre-hooks on the
schema reject `updateOne`, `updateMany`, `findOneAndUpdate`,
`findOneAndReplace`, `replaceOne`, `deleteOne`, `deleteMany`,
`findOneAndDelete`, plus any `save()` against an existing document.
`timestamps` is reduced to `{ createdAt: true, updatedAt: false }`
since a mutable timestamp would imply mutation is allowed, and
`updatedAt` is dropped from `AuditLog` / `IAuditLog`. The methods
spec resets state between tests via the raw driver (`AuditLog.collection.deleteMany`),
which bypasses the pre-hooks; new specs assert that the model-level
update / delete / re-save paths reject with the append-only error and
that `updatedAt` is not stamped on new documents.

* ♻️ refactor: Share MAX_AUDIT_LOG_LIMIT between methods and handler

Renamed the methods-layer constant from the generic `MAX_LIMIT` to
`MAX_AUDIT_LOG_LIMIT`, exported it through `@librechat/data-schemas`,
and consumed it from the handler instead of duplicating `500` there.
Now the limit is single-sourced; bumping it once updates both the
clamp inside `listAuditLogPage` and the 400-error boundary the
handler returns to clients.

* 🛡️ feat: Gate audit-log routes on a dedicated `READ_AUDIT_LOG` capability

The audit-log routes were gated on `ACCESS_ADMIN`, which conflates "can log
into the admin panel" with "can see who granted what to whom." Anyone with
`ACCESS_ADMIN + READ_CONFIGS` (a config reviewer with no people-management
authority) could read the grant history of every user, group, and role —
information they have no need to know.

`READ_AUDIT_LOG` ('read:audit_log') is now an explicit, separately grantable
read capability with no MANAGE counterpart, matching the append-only nature
of the collection. `seedSystemGrants` iterates `Object.values(SystemCapabilities)`
so existing ADMIN-role seeds pick it up automatically on next startup.

This also makes an "auditor" persona possible: hold `ACCESS_ADMIN + READ_AUDIT_LOG`
without any MANAGE_* grants and you can review history without modifying anything.

* ♻️ refactor: Share AUDIT_ACTIONS, tighten audit dep types, document route order

Exports a runtime AUDIT_ACTIONS array from packages/data-schemas alongside the
AuditAction type so the Mongoose schema enum and the HTTP handler's whitelist
consume one source of truth instead of duplicating the literal pair.

Switches the grants handler's recordAuditEntry dep typing from a duplicated
inline object literal returning Promise<unknown> to the published
RecordAuditEntryInput type returning Promise<void>, and tightens the local
emitAudit args to AuditAction. Replaces the local ParsedFilters interface in
the audit-log handler with Omit<AuditLogFilters, 'offset' | 'limit'> to drop
the duplicate definition.

Drops the optional marker on AuditLog.createdAt. Mongoose always sets it at
insert time, so callers treating it as nullable were guarding against a state
the schema does not produce.

Adds a comment on api/server/routes/admin/audit.js noting that /export.csv
must precede /:id so a future contributor does not accidentally reorder them
into a 404 trap.

* 🛡️ feat: Resolve audit names without extra DB round-trips

For the actor name, JWT-authenticated `req.user` already carries `name`,
`username`, and `email`. `resolveUser` now derives the actor display name
from `req.user` directly and threads it through the caller context, so
every grant assign and revoke no longer triggers a separate `getUserById`
lookup.

For the target name, replaces the previous always-store-the-principalId
behavior (which buried opaque ObjectId strings in immutable audit rows
for USER and GROUP targets) with a `resolveTargetName` dep. ROLE
principals continue to use `principalId` directly because the SystemGrant
model stores role names there. USER and GROUP principals route through
the new dep, which in `api/server/routes/admin/grants.js` calls
`db.getUserById` or `db.findGroupById` respectively and falls back to
the principalId on miss or error so the audit row stays intelligible.

Drops the misleading "display name lookup happens in a later iteration"
comment.

*  test: Cover audit emission, scope emitAudit to today's ROLE-only surface

Fixes a misleading test that claimed to verify "idempotent even if the grant
does not exist" while mocking deletedCount: 1 (the grant DID exist). Replaces
it with the actual no-op scenario (deletedCount: 0) and adds an assertion
that recordAuditEntry is NOT called, since the whole point of the
deletedCount > 0 gate is to avoid fictitious revocation rows.

Adds a dedicated audit emission describe block covering: grant_assigned
emission with the actor name resolved from req.user, grant_removed
emission when deletedCount is positive, and the no-emission fallback when
recordAuditEntry is not configured. The actor-name assertions exercise the
name / username / email fallback chain in resolveUser.

The previous commit also added a `resolveTargetName` dep and an
emitAudit branch for USER/GROUP targets. The grants surface is ROLE-only
today (MANAGE_CAPABILITY_BY_TYPE has only PrincipalType.ROLE), so that
code path is unreachable from the handler. Removed the dep and the
branch; the audit row uses principalId as the target name, which is the
human-readable role name for ROLE principals. A comment in emitAudit
flags where to plumb resolveTargetName back in once USER and GROUP
grants are enabled.

* 🛠️ fix: Inclusive `to` date filter and reject inverted ranges

A `?to=2025-01-15` filter previously stopped at midnight UTC of that
day, silently excluding everything that happened on January 15. The
`parseIsoDate` helper now widens a bare `YYYY-MM-DD` to 23:59:59.999Z
when called with the `end` boundary. Full ISO timestamps are honored
exactly, so callers that want minute-precision can still get it.

Also rejects inverted ranges (`from` later than `to`) with a 400 so
operators see a clear error instead of a silent empty result.

* 🛡️ feat: Cap audit-log CSV exports at 100k rows; cover stream error path

Introduces MAX_AUDIT_EXPORT_ROWS (100k) and threads a `maxRows` option
through streamAuditLogEntries. The handler now passes the cap into the
stream so a careless admin script or a hostile auditor cannot pin a
Node worker and a Mongo cursor by exporting unbounded result sets.
Beyond 100k rows, callers should slice exports by from / to date.

Adds a methods-layer spec for the cap behavior, a handler-layer spec
that asserts the option is plumbed through, and a handler-layer spec
that exercises the streamAuditLogEntries-throws-after-headers-sent path
(catch block falls through to res.end instead of attempting JSON).

Documents on buildFilter that case-insensitive substring regex filters
(actorName, targetName, capability, search) cannot use a B-tree index
and degrade to a tenant-scoped partition scan, so deployments with
hundreds of thousands of audit rows per tenant should constrain those
queries with a date window.

* 🧹 chore: Spell CSV_BOM as  and drop a gratuitous optional chain

`revokeCapability` is typed `Promise<{ deletedCount: number }>` so the
`?.` on `revokeResult?.deletedCount` only obscured that the value cannot
be nullish.

`CSV_BOM` was a literal U+FEFF character invisible in most editors. Now
spelled as the Unicode escape so readers can see the constant; the test
that asserts on the first emitted chunk uses the same escape.

* 🔧 chore: Allowlist AuditLog in the tenant-isolation coverage guard

The AuditLog collection carries a tenantId field but scopes tenancy manually
inside listAuditLogPage / streamAuditLogEntries / recordAuditEntry using the
same $exists: false convention as SystemGrant. The tenant-isolation plugin
coverage spec now allows that and asserts it stays accurate.

* 🛠️ fix: Normalize blank tenantId before persisting audit entries

The `recordAuditEntry` write path was treating any non-null tenantId as a
real string, so empty or whitespace-only values reached the schema validator,
failed the non-empty-string check, and silently dropped the audit row. The
read-side `tenantFilter` already treats those values as platform-level scope,
so the write path now mirrors it: blank or whitespace-only tenantId becomes
an omitted field, which matches `{ tenantId: { $exists: false } }` queries
and clears validation. Added a regression test that records two entries with
blank and whitespace tenantId and asserts both persist with the tenantId
field absent.

* 🎨 style: collapse expect.objectContaining onto one line to satisfy prettier

* 🔒 fix: block document-level deleteOne/updateOne on AuditLog

Mongoose registers deleteOne and updateOne pre-hooks as query middleware
by default. The query-level append-only block on AuditLog therefore did
not cover Document.prototype.deleteOne() or Document.prototype.updateOne(),
leaving a path where a caller that had already loaded an audit row via
findOne could call .deleteOne() or .updateOne() on the instance and bypass
the schema contract.

Explicit { document: true, query: false } registrations close the holes,
and the spec now covers both code paths against a real in-memory Mongo.

* 🔒 fix: require ACCESS_ADMIN on audit-log routes

Every other admin router (config, grants, users, roles, groups, auth)
enforces requireJwtAuth followed by requireCapability(ACCESS_ADMIN) before
any feature-specific capability check. The audit-log router only required
READ_AUDIT_LOG, which is independent of ACCESS_ADMIN in CapabilityImplications,
so a role delegated only READ_AUDIT_LOG without ACCESS_ADMIN could read or
CSV-export the audit trail and bypass the admin boundary.

Aligned the middleware chain with the rest of the admin surface so
ACCESS_ADMIN gates entry and READ_AUDIT_LOG gates the feature within it.

* 🎨 chore: re-sort imports after dev rebase

Post-rebase sort-imports against the merge target — six audit-log files
landed with stale import ordering relative to the current scripts/sort-imports.mts
rules on dev. CI's import-order job flagged the drift; running the script
locally rewrites them in place. No semantic changes.

* 🔧 fix: explicit type annotations on audit-log model + schema exports

Dev migrated packages/data-schemas builds from rollup to tsdown with
--isolatedDeclarations enabled, which requires every exported function to
declare its return type and every exported variable to declare its type.
Two of our audit-log exports got swept up:

  TS9007 models/auditLog.ts:12  createAuditLogModel return type
  TS9010 schema/auditLog.ts:12  auditLogSchema variable type

Added Model<t.IAuditLog> on the factory and Schema<IAuditLog> on the
schema variable, matching the sibling SystemGrant convention. No runtime
behavior change.

* 🔧 fix: align revokeCapability type annotation with implementation

The rebase auto-merge of systemGrant.ts kept dev's outer type annotation
(`revokeCapability: ... => Promise<void>`) but our implementation returns
`Promise<{ deletedCount: number }>` (added during the bot-review loop to
let the audit emitter distinguish a real revoke from a no-op against a
nonexistent grant). The mismatch surfaced as TS2719 on the methods record
return at line 520. Updated the type annotation to match the impl.

The caller at packages/api/src/admin/grants.ts:444 reads
`revokeResult.deletedCount` to gate the audit emit, so the wider return
type is what the rest of the code already assumes.

* 🔧 fix: explicit factory return type on createAdminAuditLogHandlers

Same tsdown --isolatedDeclarations migration that hit packages/data-schemas
also applies to packages/api; the audit-log handler factory's inferred
return type tripped TS9013 against the new build pipeline. Annotated the
factory with explicit handler signatures matching the sibling
createAdminGrantsHandlers convention. Used Promise<Response | void> for
the export handler because its final res.end() path returns undefined,
unlike the other two handlers which always return a Response.

* 🛡️ feat: Generalize audit log into a tamper-evident, extensible event substrate

Reworks the SystemGrant-only audit log into a general-purpose, append-only
compliance substrate designed to absorb future event classes (agent runs,
tool/MCP calls, config + permission changes, approvals) without reshaping the
record. Nothing was shipped yet, so this replaces the grant-specific wire
shape rather than layering aliases.

Schema / record shape (packages/data-schemas):
- schemaVersion + two-level taxonomy: category + namespaced action
  (grant.assigned/grant.removed), first-class outcome and severity.
- Structured actor{type,id,name} supporting non-user actors (system, agent,
  service, schedule, webhook, api); generic target{type,id,name}; open
  metadata map; request context{requestId,ip,userAgent,sessionId}.

Tamper-evidence (hash chain):
- Per-tenant chain keyed by chainKey with seq/prevHash/hash. Appends link to
  the previous hash; a unique {chainKey,seq} index serializes concurrent
  writes (dup-key retry) so the chain can never fork. createdAt is explicit so
  it's covered by the hash.
- verifyAuditChain() walks a chain and detects modification, deletion, and
  forged links; exposed via GET /api/admin/audit-log/verify.

Other best-practice gaps from the review:
- Keyset (cursor) pagination over seq alongside offset; stable under
  concurrent appends. nextCursor in the page payload.
- Retention: purgeAuditLogEntries() privileged prefix-purge with a confirm
  latch, returns a checkpoint; verify tolerates a purged prefix.
- Fail-closed option (AUDIT_LOG_FAIL_CLOSED) so a failed audit write can fail
  the grant request instead of being swallowed; default stays fail-open.
- Grant handlers now capture request context and emit the new shape.

CSV export updated for the new columns (incl. seq/hash). data-schemas bumped
to 0.0.54 for the sibling admin-panel consumer. Tests rewritten: 28
methods-layer cases (chain genesis/linking, tamper detection, keyset, purge)
and the handler/grants specs updated for the new shape, fail-closed, and the
verify endpoint.

* 🛠️ fix: Address Codex review on the audit-log substrate

- F1 (fail-closed atomicity): assign/revoke now compensate (rollback grant /
  restore grant) when a fail-closed audit write fails, so a 5xx never leaves an
  unaudited mutation.
- F5: only emit grant.assigned for a real change — skip the audit when the role
  already holds the capability (idempotent re-assert).
- F7: verifyAuditChain no longer silently trusts a non-genesis start; a purged
  prefix must be authorized by a trusted checkpoint (purge now returns
  {throughSeq, prevHash}), else verification fails as tampering.
- F4: block Model.bulkWrite on AuditLog (would bypass the append-only middleware).
- F3: CSV export appends an explicit TRUNCATED marker + logs when the row cap is hit.
- F6: reject out-of-range date-only filters (2025-02-31) instead of normalizing.
- F2: regenerate package-lock.json for the 0.0.54 data-schemas bump.

Tests: +1 methods (bulkWrite) +2 verify (deleted-prefix / checkpoint mismatch),
updated purge test for checkpoint flow; +4 api (re-assert skip, assign/revoke
fail-closed rollback, date reject, CSV truncation marker).

* 🛠️ fix: Address Codex round-2 on the audit-log substrate

- R2-1/R2-5 (P1/P2): base the grant.assigned audit decision on the atomic
  upsert result. grantCapability now returns { grant, created } via
  includeResultMetadata; the handler audits only when created. Removes the racy
  pre-read, which also mis-handled inherited platform grants vs a new
  tenant-scoped insert and concurrent double-assign.
- R2-2 (P2): namespace tenant chain keys (tenant:<id>) so a tenant whose id is
  literally the platform sentinel can't share the platform audit chain.
- R2-4 (P2): validate literal calendar tokens for full ISO timestamps too, so
  2025-02-31T00:00:00Z is rejected instead of normalizing to March 3.

Tests updated for the grantCapability { grant, created } contract (systemGrant +
grants specs) and the namespaced chain key (auditChainKey helper); +1 api date
case. data-schemas 141, api grants/audit 107 green.

R2-3 (deprecated actorId/targetPrincipalId aliases): not reinstating — the
surface is pre-release and its only consumer (admin-panel PR) migrates to the new
shape in lockstep, so there are no legacy clients to support.
R2-6 (role-deletion cascade emits no grant.removed): valid but a separate
workflow in roles.ts; tracked as a follow-up to keep this PR scoped.

* 🛠️ fix: Address Codex round-3 on the audit-log substrate

- R3-3 (P2): make a grant re-assert a true no-op — move grantedAt/grantedBy to
  $setOnInsert so an existing grant is never silently mutated when the audit is
  skipped (created:false now means nothing changed). grantedAt/grantedBy record
  the original grant.
- R3-2 (P2): report CSV export truncation exactly. streamAuditLogEntries returns
  { count, truncated }; truncated is true only when rows existed beyond the cap,
  so an exact-cap export is no longer falsely marked truncated.
- R3-5 (P2): block AuditLog.insertMany (another bulk path that skips the save
  hook and could inject forged seq/prevHash/hash and poison the chain).

Tests: +insertMany rejection, +exact-cap vs truncated stream cases, +exact-cap
export-not-truncated handler case. ds 142, api 108 green.

R3-1 (deprecated query aliases) and R3-4 (role-deletion cascade audit) are
re-flags of R2-3/R2-6 — holding the prior decisions (pre-release surface; separate
roles.ts workflow tracked as a follow-up), pending maintainer direction.

* 🛡️ feat: Audit grant removals from the role-deletion cascade

Closes the forensic gap Codex flagged (R2-6/R3-4): deleting a role removed its
SystemGrants with no audit entries. `deleteGrantsForPrincipal` now returns the
removed grants, and the role-deletion handler emits a `grant.removed` audit entry
per removed grant (actor = caller, target = role, metadata.capability, request
context), matching the explicit revoke endpoint. Fail-open — the role is already
deleted, so a failed audit is logged, not propagated; sequential to keep the
per-tenant hash chain ordered.

Extracted `buildAuditContext` to admin/context.ts (shared by grants + roles).
Tests: role-deletion emits one entry per grant / none when no grants; ds 110,
api admin 202 green.

* 🛠️ fix: Address Codex round-4 on the audit-log substrate

- R4-1 (P2): don't silently drop an audit row under heavy append contention.
  recordAuditEntry now retries duplicate-key seq collisions up to 12× with
  jittered backoff (was 5, no backoff), so realistic bursts of parallel admin
  writes resolve; the failClosed escape still applies on true exhaustion.
- R4-3 (P2): purge a contiguous seq prefix, not a date range. createdAt is
  app-generated, so under multi-instance clock skew a later seq can carry an
  earlier timestamp; a raw date delete could remove an interior row and break
  verification. purgeAuditLogEntries now resolves the date to the first retained
  seq and deletes only strictly-lower seqs, keeping the remaining chain contiguous.

Tests: +clock-skew purge case (no gap created). ds auditLog 33 green.

R4-2 (role-deletion grant audit) is a re-flag of R2-6/R3-4, already implemented
in 15472127d6 (roles.ts emitGrantRemovals + route wiring + tests); the finding's
cited line numbers predate that commit.

* 🛠️ fix: Address Codex round-5 on the audit-log substrate

- R5-1 (P2): scope each cascade grant.removed entry to the removed grant's own
  tenant, not the caller's. A platform admin deleting a role can remove
  tenant-scoped grants; those removals now land in the affected tenant's chain.
- R5-2 (P2): only return a purge checkpoint when rows were actually deleted. A
  no-op confirmed purge no longer mints a trust boundary that could legitimize a
  prefix it didn't authorize.
- R5-3 (P2): ensure the unique { chainKey, seq } index exists before appending
  (memoized createIndexes), so serialization doesn't depend on a background build
  — closes a silent chain-fork window under MONGO_AUTO_INDEX=false or at startup.

Tests: +per-grant-tenant cascade audit, +no-op-purge-no-checkpoint,
+index-built-before-append. ds auditLog 35, api roles 95 green.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-18 15:42:33 -04:00
Dustin Healy
fa20003952
🛂 refactor: Accept Targeted assign:configs for Config Scope-Lifecycle Endpoints (#13773)
* 🔓 fix: Accept Targeted assign:configs for Config Scope-Lifecycle Endpoints

Three admin-config endpoints currently require broad manage:configs: PUT /:principalType/:principalId for empty-overrides scope creation, DELETE /:principalType/:principalId for scope removal, and PATCH /:principalType/:principalId/active for the active toggle. The capability model already defines assign:configs:user|group|role for delegated administrators and validates that shape in isValidCapability, but no handler accepts it, so a delegate granted assign:configs:role via /api/admin/grants cannot manage scope lifecycle for the principal type they were explicitly delegated.

This aligns the server-side auth with the documented capability surface. Every destructive lateral path stays behind broad manage:configs: operations against the base config principal (__base__), non-empty PUT payloads that $set the full overrides field, and DELETE or toggle on a document whose existing overrides are non-empty (which would erase or neutralize sections the caller could not author). The new hasCapability dep on AdminConfigDeps is optional with a false default, so external consumers continue to get pre-PR behavior until they wire the resolver.

* 🛡️ fix: Block Assign-Only Scope-Lifecycle When Existing Doc Has Tombstones

The existing-overrides guard introduced in the prior commit only checked overrides, but configs also carry tombstones (suppressed inherited field paths) which are iterated during cascade resolution. An assign-only caller could delete, toggle, or empty-upsert a doc whose overrides is empty but whose tombstones is non-empty, which would erase or neutralize suppressions on fields they could not author. Extends the guard at all three call sites to treat a non-empty tombstones array as destructive state.

* 🚨 fix: Log TOCTOU Race When Assign-Only Lifecycle Op Hits Non-Empty Doc

The empty-state guard for assign-only callers performs a read-then-write across two DB roundtrips, so a concurrent broad-manage write can land between the guard and the destructive op. Adds post-write detection on the delete and toggle handlers: when the destructive op returns a doc whose state was non-empty at write time, emit logger.warn with the caller id, principal, and observed-state counts so ops can detect the race and restore from audit logs.

A fully atomic fix would require extending deleteConfig, toggleConfigActive, and upsertConfig in packages/data-schemas/src/methods/config.ts to support compare-and-swap filters, which is a wider design change than this PR's auth scope. Empty-payload upsert is not covered because $set replaces overrides, so the post-write doc no longer reflects pre-write state.

* 🔒 fix: Atomic Empty-State Filter for Assign-Only Scope-Lifecycle Writes

Replaces the read-then-check guard with an atomic Mongo filter on the destructive write itself. Adds an options.expectEmpty parameter to deleteConfig, toggleConfigActive, and upsertConfig in the shared data-schemas layer. When set, the filter requires both overrides and tombstones to be empty before the write matches. The TOCTOU race window is eliminated: a concurrent write cannot land between the empty-state check and the destructive op because they are now a single atomic operation.

For upsertConfig, the E11000 retry path returns null instead of falling back to a filterless update when expectEmpty is set, preserving the atomic property. Handlers fall back to findConfigByPrincipal only to disambiguate the null return between 404 (doc absent) and 403 (doc exists with non-empty state). The post-write logger.warn race detection added in the prior commit is removed as unreachable.
2026-06-18 15:40:58 -04:00
Dustin Healy
84886c56fb
🧷 fix: Preserve Document Priority on Section-Scoped Config Writes (#13772)
The patch and tombstone admin-config handlers accept a priority field on the request body, which controls the position of the entire Config document in the precedence cascade. Today, that priority is written unconditionally, even when the caller holds only a section-scoped grant such as manage:configs:memory. On a document containing overrides for other sections, this lets a section-scoped caller silently reorder overrides they have no permission to author.

This is a defense-in-depth fix for deployments using section-scoped grants. In a vanilla setup where admins hold broad manage:configs, the path is unreachable because the broad-capability short-circuit lets every priority change through, so the fix is a no-op for those callers. The change makes the contract safe-by-construction for any deployment that narrows the auth model, at no cost to upstream behavior.
2026-06-18 15:39:44 -04:00
Danny Avila
68d142d0e9
🦜 refactor: Use path for Read/Write/Edit/Create File Tools (#13834)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* fix(agents): use `path` for read/write/edit/create file tools

Pairs with @librechat/agents renaming the read_file/write_file/edit_file tool
parameter from `file_path` to `path` (models — esp. Kimi K2 — emit `path` far
more reliably, and it matches grep/glob/list_directory which already use `path`).

- tools.ts: LibreChat's own code/skill file-tool schemas use `path`
  (the skill read_file tool inherits the SDK definition, which is already renamed)
- handlers.ts: read `args.path` for the model-facing tool arg + error messages
- the internal host `readSandboxFile`/`writeSandboxFile` contract is unchanged
- tests updated

Requires @librechat/agents with the param rename (danny-avila/agents#250). All
agents unit suites green (175).

* chore: update @librechat/agents to v3.2.41 and bump related dependencies in package-lock.json and package.json files

* fix(api): Refactor header merging in MCPConnection to use Object.assign for clarity

* test(e2e): mock emits `path` for create/edit file-authoring tools

The mock LLM still sent `file_path` for the create_file/edit_file calls, which the
renamed handlers no longer read -> the skill-file-authoring e2e failed with
'Expected skill to be persisted'. Switch the fixture to `path` to match the tools.
(The internal readSandboxFile/writeSandboxFile contract stays on `file_path`, so
api/server/services/Files/Code/process.js and its spec are unchanged.)
2026-06-18 14:44:51 -04:00
Marco Beretta
d8474864e9
🕰️ feat: Resolve Agent Prompt Time Variables in User's Timezone (#13815)
Server-side resolution of {{current_date}} and {{current_datetime}} for
agent instructions used the server's timezone, so agents received UTC
instead of the user's local time the variables are documented to provide.

The browser's IANA timezone is now sent with each request and threaded
through replaceSpecialVars, anchoring those variables to the user's local
wall clock. {{iso_datetime}} stays UTC. Invalid or missing zones fall back
to the previous behavior.
2026-06-18 08:39:56 -04:00
Danny Avila
a6b5343220
📦 chore: npm audit fix (#13828)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* 🔧 chore: Update `@librechat/agents` to v3.2.38 and bump related dependencies in package-lock.json and package.json files

* 🔧 chore: Upgrade `multer` dependency to version 2.2.0 in package-lock.json and package.json

* 🔧 chore: Upgrade `nodemailer` dependency to version 9.0.1 in package-lock.json and package.json

* 🔧 chore: Upgrade `@aws-sdk/client-bedrock-agent-runtime` and `@aws-sdk/client-bedrock-runtime` to versions 3.1071.0, update related dependencies in package-lock.json and package.json

* 🔧 chore: Upgrade `form-data` to version 4.0.6 and `hono` to version 4.12.25, update related dependencies in package-lock.json and package.json

* 🔧 chore: npm audit fix

* 🔧 chore: Remove unused Babel dependencies from package-lock.json and package.json

* 🔧 chore: Add '@mistralai/mistralai' to esModules in Jest configuration files
2026-06-17 21:54:04 -04:00
Ravi Kumar L
27b0782201
📛 feat: Tag Langfuse Traces With Tenant ID (#13808)
* feat: tag Langfuse traces with tenant id

* fix: propagate tenant id to agent Langfuse config
2026-06-17 20:27:55 -04:00
Danny Avila
8628897c9c
📦 chore: Bump @librechat/agents to v3.2.37 (#13826) 2026-06-17 20:27:36 -04:00
Danny Avila
49f4b659f6
🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart (#13814)
* 🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart

MCPServersRegistry was built once at boot from getAppConfig({ baseOnly:
true }), freezing allowedDomains/allowedAddresses to YAML. Admin-panel
mcpSettings overrides were ignored by both inspection (addServer/
reinspectServer/updateServer/lazyInitConfigServer) and runtime connection
enforcement (assertResolvedRuntimeConfigAllowed), so a domain allowed only
via the panel failed inspection and never connected.

Make the registry's effective allowlists mutable and refresh them from the
merged admin-panel config: seed at boot, and re-apply on every config
mutation via invalidateConfigCaches -> clearMcpConfigCache. Both inspection
and connection paths read the same getters, so both honor overrides without
a restart. Fail-safe: current allowlists are preserved when the merged read
fails.

* 🛡️ fix: Scope MCP allowlist refresh to global config, fail-safe on DB error

Address Codex P1 review findings on the allowlist-refresh path:

- Tenant-scoped config mutations no longer push one tenant's merged
  mcpSettings into the process-wide registry singleton (read by all MCP
  connection paths), which would leak allowlists across tenants. Only
  global (non-tenant) mutations refresh the registry; tenant mutations
  still evict the config-server cache.
- The refresh read now uses strictOverrides:true so a transient DB error
  throws instead of silently returning YAML base config — preserving the
  last-known allowlists rather than overwriting them with fallback values.
  Adds the strictOverrides option to getAppConfig (default off, no behavior
  change for existing callers).

* ♻️ refactor: Resolve MCP allowlists per-request (tenant-scoped) instead of a global singleton

Supersedes the prior global-mutation approach. MCP allowlists live in
mcpSettings, which is tenant/principal-scoped admin config, so a process-wide
singleton value is the wrong model — it caused cross-tenant bleed and stale
reads.

Instead, inject a resolver (from the app layer, where the merged config lives)
that the registry calls per inspection and per connection. It reads the ALS
tenant context via getAppConfig and accepts the acting user so user/role-scoped
overrides resolve; config-source inspection (no user) resolves at tenant scope.
Falls back to the YAML base allowlists when no resolver is set or the lookup
fails, so a transient error fails to the operator baseline rather than
disabling the allowlist.

Removes the now-unnecessary setAllowlists / boot-seed / invalidateConfigCaches
refresh / getAppConfig.strictOverrides machinery.

* 🔒 fix: Scope config-source cache by allowlist; resolve OAuth allowlists per-request

Address Codex review of the per-request resolver:

- Config-source cache key now folds in the resolved allowlists, not just the
  raw-config hash. Inspection results became allowlist-dependent, so without
  this a tenant whose allowlist rejects a URL could poison the shared key with
  an inspectionFailed stub for a tenant that allows it (and vice versa). The
  tenant-scoped allowlist is resolved once per ensureConfigServers pass and
  threaded through the cache key + inspection.
- The two remaining request-time OAuth allowlist reads now use the merged
  config instead of the YAML base getters: the fallback OAuth-initiate path
  (routes/mcp.js) via resolveAllowlists, and OAuth revocation
  (UserController.maybeUninstallOAuthMCP) via the request's already-merged
  appConfig.mcpSettings. Without this, an OAuth endpoint allowed only by an
  admin-panel override was rejected while inspection/connection allowed it.

*  test: Update MCP OAuth registry/config mocks for per-request allowlists

CI fix for the Finding-12 change. The OAuth-initiate route now calls
registry.resolveAllowlists() and the revocation path reads the merged
appConfig.mcpSettings, so the affected specs' mocks were asserting the old
base-getter values:
- routes/__tests__/mcp.spec.js: add resolveAllowlists to the registry mock.
- UserController.mcpOAuth.spec.js: provide mcpSettings on the getAppConfig
  mock so revokeOAuthToken still receives the expected allowlists.

* 🧪 test: e2e proof that admin-panel MCP allowlist override takes effect

Adds a Playwright mock-harness spec for #13809. A URL-based MCP fixture
(e2e-http, streamable-http SDK server) boots inspectionFailed because its
origin is omitted from the YAML mcpSettings.allowedDomains; the spec adds that
origin via an admin config override (PUT /api/admin/config/user/:id) and
asserts the server reinitializes — exercising the real resolver path through
the backend + DB. Before the fix, reinspection used the frozen YAML allowlist
and the server stayed unreachable.

- e2e/setup/fake-mcp-http-server.js: streamable-HTTP MCP fixture (health GET /).
- e2e/playwright.config.mock.ts: boot the fixture as a second webServer.
- e2e/config/librechat.e2e.yaml: mcpSettings.allowedDomains (excludes 127.0.0.1)
  + the e2e-http server.
- e2e/specs/mock/mcp-allowlist-override.spec.ts: login → baseline reinit fails →
  apply override → reinit succeeds.
2026-06-17 20:14:53 -04:00
Danny Avila
fdc7e64bb7
🪙 feat: SDK-Aligned Context-Usage Projection (gauge for window-switch & snapshot-less branches) (#13801)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🪙 feat: Context-usage projection — data-provider + client wiring

Consumer side of the SDK-aligned context projection (agents
`projectAgentContextUsage`). Adds the `/api/endpoints/context-projection`
data-provider plumbing (endpoint, service, query key, `TContextProjectionRequest`)
and a `useContextProjectionQuery` gated to fire only when no fresh snapshot
covers the viewed branch.

Wires `useTokenUsage` precedence to: live snapshot → fresh persisted snapshot
(window matches the resolved one) → server projection → per-message estimate.
A model/window switch marks the baked snapshot stale (its `maxContextTokens`
no longer matches) and falls to the projection — closing the gauge's
window-switch (G1) and snapshot-less-branch (G2) gaps. Snapshot and projection
share the render-relevant fields, so they render uniformly.

Backend endpoint + agents version bump land in follow-up commits. Includes the
design spec (CONTEXT_PROJECTION_SPEC.md).

* 🪙 feat: Context-projection backend endpoint

POST /api/endpoints/context-projection → resolveContextProjection (packages/api):
reconstructs the viewed branch (parent-chain walk from messageId), resolves the
agent config (instructions/provider/model/maxContextTokens), reuses LibreChat's
stored per-message tokenCounts as the index map (no re-tokenizing), and calls
the agents SDK projectAgentContextUsage — no model call. Thin controller injects
db.getMessages/db.getAgent; route mirrors /token-config.

First cut targets message-windowing accuracy; tool-schema tokens are deferred to
a follow-up that reuses the full initializeAgent path.

* 🩹 fix: Codex review on context projection (G1 guard, IDOR, recount, summary)

- Guard `currentActive` against a stale window: a model/window switch on the
  current branch left the live snapshot outranking the projection (G1 didn't
  fire). Now defers to the projection unless streaming or the window matches.
- Scope branch lookups to the authenticated user (`getMessages` filter +
  injected `userId`) — was loading any conversation by id (IDOR).
- Recount messages with no stored `tokenCount` via the tokenizer instead of
  charging 0, so snapshot-less/imported histories don't under-report.
- Fall back (null) for already-summarized branches rather than projecting from
  the full raw parent chain (the next call would send summary + tail); the
  client's summary-baseline-aware estimate handles them until a follow-up
  replays the summary boundary.

* 🩹 fix: Codex round 2 — drop agent load, summary marker, edit-invalidation

- Stop loading agent/model-spec config server-side (closes the agent-access
  IDOR and the spec-prompt special-casing). Provider/model/window now come from
  the client-resolved request (`limits.endpoint`/model — the agent's real
  provider, not the `agents` endpoint, so the tokenizer is right). Agent/spec/
  promptPrefix instructions are uniformly deferred to the full-fidelity follow-up.
- Detect summarized branches via the live path's `metadata.summaryUsedTokens`
  marker (was the wrong `summaryTokenCount` field) and fall back to the
  summary-aware estimate.
- Invalidate the projection query on in-place message edits via a branch
  content `revision` in the cache key (the tail id is unchanged on edit).

Deferred (valid, not a regression): same-window endpoint/model switch keeps a
window-matched snapshot — needs endpoint/model persisted on the snapshot, which
lands with the fidelity follow-up. Smoke-tested: fits / prunes / summarized→null
/ no-window→null.

* 🛡️ fix: make context projection strictly additive (no-regression)

Revert the G1 window-match guard on the live/branch snapshot. When no explicit
maxContextTokens is set (the common default), the SDK's snapshot window is
reserve-derived (~0.9·(modelContext − maxOutputTokens)) while useTokenLimits
resolves the raw model context — so `snapshot.maxContextTokens === resolvedMax`
is false for the SAME model, and the guard would wrongly drop a valid
current-branch snapshot to projection/estimate post-stream (a regression in the
default case, per initialize.ts:1240-1243).

The projection now activates ONLY for snapshot-less branches (G2): the
precedence is live snapshot → persisted branch snapshot → projection → estimate,
where the first two are byte-for-byte the prior behavior and the projection just
slots ahead of the estimate. Window/model-switch (G1) detection needs the
snapshot to carry its model/window and defers to the fidelity follow-up.

* 🩹 fix: surface projections as estimates, not authoritative snapshots

A first-cut projection carries the SDK's windowing but omits instruction/tool
overhead, so rendering it as `isEstimate: false` showed a confident under-count
for snapshot-less branches. Mark projection-sourced views `isEstimate: true` +
`snapshotActive: false` (and drop the snapshot field) so they present as a
better estimate than sumBranch — improved used/window number, estimate framing,
no misleading granular breakdown with ~0 tools. Real snapshots stay
authoritative. (Codex round 3, projection.ts:139.)

* 🧹 chore: drop CONTEXT_PROJECTION_SPEC.md from the PR

* 🎨 style: fix import-sort order in projection.ts (CI sort-imports check)

* 🔧 chore: update @librechat/agents dependency to version 3.2.36 in package-lock.json and related package.json files

* chore: npm audit fix

* 🎨 style: fix import-sort order in data-service.ts (CI sort-imports check)

* 🩹 fix: drop dead calibrationRatio in projectionParams (tsc never error)

Inside the ternary, branchSnapshot is narrowed to null (the gate is
), so  accessed a
property on  (frontend typecheck failure). It was also dead — there is
never a snapshot to seed from in this branch — so just remove it.

* Revert "chore: npm audit fix"

This reverts commit 4cdb862d0c.
2026-06-16 17:54:13 -04:00