Compare commits

..

136 Commits

Author SHA1 Message Date
Danny Avila
9ab47d7e21 feat: Add GitHub link converter script for Markdown formatting 2025-09-24 08:48:18 -04:00
github-actions[bot]
b85950aa9a 🌍 i18n: Update translation.json with latest translations (#9789)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-24 07:13:08 -04:00
Danny Avila
bcec5bfceb 🆔 fix: Prioritize Immutable Sub Claim for OIDC User ID (#9788)
* add use of immutable claims to identify user object

* fix semicolons

* update email attribute on change

* replace ternary expressions

* fix semicolon

* chore: add typing

* chore: reorder fields in `findOpenIDUser`

* refactor: optimize user lookup logic in `findOpenIDUser` function to minimize database roundtrips

* refactor: integrate findOpenIDUser for improved user retrieval in refreshController

* refactor: improve error logging for invalid refresh tokens in refreshController

* ci: mock findUser correctly in openidStrategy tests

* test: add unit tests for findOpenIDUser function to enhance user retrieval logic

---------

Co-authored-by: Joachim Keltsch <joachim.keltsch@daimlertruck.com>
2025-09-23 14:46:53 -04:00
MyGitHub
e4f323e71a 🌐 feat: Helm DNS Configuration Support for Traffic Redirection (#9785)
This PR adds DNS configuration support to the LibreChat Helm chart, enabling users to redirect traffic to proxy servers or use custom DNS settings.

## What's Changed
- Added dnsPolicy and dnsConfig fields to deployment.yaml template
- Added DNS configuration options to values.yaml with comprehensive examples
- Created documentation and example configurations

## Use Cases
- Redirect AI service traffic (AWS Bedrock, OpenAI, etc.) to proxy servers
- Use corporate DNS servers for name resolution
- Control traffic routing through custom DNS configurations
- Enforce traffic through security gateways

## Configuration Example
```yaml
dnsPolicy: "None"
dnsConfig:
  nameservers:
    - "10.0.0.10"  # Custom DNS server for redirections
  searches:
    - "svc.cluster.local"
  options:
    - name: ndots
      value: "2"
```

## Testing Results
 Successfully tested with Docker Compose environment
 DNS resolution correctly redirects to configured IPs
 HTTP requests properly routed to proxy servers
 Tested with multiple domains (AWS Bedrock, OpenAI, SageMaker)

Test output:
- bedrock-runtime.us-east-1.amazonaws.com -> 172.25.0.10 ✓
- api.openai.com -> 172.25.0.10 ✓
- sagemaker-runtime.us-east-1.amazonaws.com -> 172.25.0.10 ✓

All DNS redirects working correctly with proxy server receiving traffic.

## Documentation
- Added comprehensive DNS_CONFIGURATION.md guide
- Included examples for common use cases
- Provided troubleshooting steps

## Backward Compatibility
This change is fully backward compatible. If dnsPolicy and dnsConfig are not specified, the default Kubernetes DNS behavior is maintained.

Fixes #[issue_number]

Co-authored-by: LibreChat User <user@example.com>
2025-09-23 10:41:58 -04:00
Ihsan Soydemir
d83826b604 🔐 feat: Support Multiple Roles in OPENID_REQUIRED_ROLE (#9171)
* feat: support multiple roles in OPENID_REQUIRED_ROLE

- Allow comma-separated roles in OPENID_REQUIRED_ROLE environment variable
- User needs ANY of the specified roles to login (OR logic)
- Maintain backward compatibility with single role configuration
- Add comprehensive test coverage for multiple role scenarios

* Add tests

* Fix linter

* Add missing closing brace

* Add new line

* Simplify tests

* Refresh OpenID verify callback in tests

* Fix OpenID spec and resolve linting errors

* test: Add backward compatibility test for single required role in OpenID strategy

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-23 10:39:34 -04:00
Jerum Hubbert
2153db2f5f 📊 feat: Helm Chart Port Flexibility and MongoDB Update (#9750)
- Update MongoDB chart dependency from v16.3.0 to v16.5.45
- Add explicit containerPort configuration with fallback to service.port
- Standardize port references in health probes to use explicit port numbers
- Add targetPort and containerPort fields to service configuration
- Include service type options as inline comment for better clarity

These changes improve the Helm chart's port management flexibility and
bring the MongoDB dependency up to date with the latest stable version.

Co-authored-by: Jerum Hubbert <jerum.hubbert@scientificgames.com>
2025-09-23 09:48:07 -04:00
Clay Rosenthal
de02892396 📦 fix: Helm Chart HPA Configuration Issues (#9770) 2025-09-23 09:44:40 -04:00
Sean McGrath
f61e057f7f 🔐 fix: MCP OAuth Token Persistence Race Condition and Refresh Auth Method (#9773)
* set supported endpoint auth method when token_url exists

* persist tokens immediately

* add token storage validation tests
2025-09-23 09:35:56 -04:00
Danny Avila
91e49d82aa 🔼 feat: Vercel App Attribution for LibreChat (#9769) 2025-09-22 16:15:15 -04:00
github-actions[bot]
880c7b43a1 🌍 i18n: Update translation.json with latest translations (#9764)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 14:58:35 -04:00
Danny Avila
c99a29f8da 📦 chore: update @librechat/agents to v2.4.80 (#9766) 2025-09-22 14:58:20 -04:00
Danny Avila
8a60e8990f 🤖 refactor: Side Panel Agent UI To Account For Ephemeral Agents (#9763)
* refactor: Remove unused imports and consolidate ephemeral agent logic

* refactor: Side Panel agent handling to account for ephemeral agents for UI

* refactor: Replace Constants.EPHEMERAL_AGENT_ID checks with isEphemeralAgent utility for consistency

* ci: AgentPanel tests with additional mock configurations and utility functions
2025-09-22 09:48:05 -04:00
Danny Avila
a6bf2b6ce3 🔐 refactor: Improve MCP Auth UX for Agent Panel (#9762)
* chore: Improve logging format for initial flow state creation

* refactor: MCP Tool Management with Improved Auth Handling and State Management

- Updated `CustomUserVarsSection` to include optional localization for placeholder text.
- Refactored `MCPToolSelectDialog` to streamline tool addition and management, including improved handling of authentication data.
- Introduced a new `addToolsToForm` function to encapsulate logic for adding tools to the form state.
- Enhanced `useRemoveMCPTool` hook to simplify tool removal logic and ensure proper state updates.
- Added loading state management for custom variable saving to improve user experience.

* refactor: Enhance MCP Tool Removal Logic and Integrate Toast Notifications

- Updated `MCPToolSelectDialog` to utilize the new `removeTool` function from the `useRemoveMCPTool` hook for improved tool removal handling.
- Refactored `useRemoveMCPTool` to accept options for toast notifications, allowing for more flexible user feedback during tool removal.
- Removed the previous inline tool removal logic to streamline the component's code and improve maintainability.

* refactor: Enhance user plugins mutation to invalidate MCP auth values on uninstall

* refactor: Replace refetchQueries with invalidateQueries for improved cache management

* chore: remove unused i18n key
2025-09-22 08:53:19 -04:00
github-actions[bot]
ff8dac570f 🌍 i18n: Update translation.json with latest translations (#9759)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 08:44:07 -04:00
Danny Avila
96870e0da0 refactor: MCP OAuth Polling with Gradual Backoff and Timeout Handling (#9752)
* refactor: Implement gradual backoff polling for oauth connection status with timeout handling

* refactor: Enhance OAuth polling with gradual backoff and timeout handling; update reconnection tracking

* refactor: reconnection timeout behavior in OAuthReconnectionManager and OAuthReconnectionTracker

- Implement tests to verify reconnection timeout handling, including tracking of reconnection states and cleanup of timed-out entries.
- Enhance existing methods in OAuthReconnectionManager and OAuthReconnectionTracker to support timeout checks and cleanup logic.
- Ensure proper handling of multiple servers with different timeout periods and edge cases for active states.

* chore: remove comment

* refactor: Enforce strict 3-minute OAuth timeout with updated polling intervals and improved timeout handling

* refactor: Remove unused polling logic and prevent duplicate polling for servers in MCP server manager

* refactor: Update localization key for no memories message in MemoryViewer

* refactor: Improve MCP tool initialization by handling server failures

- Introduced a mechanism to track failed MCP servers, preventing retries for unavailable servers.
- Added logging for failed tool creation attempts to enhance debugging and monitoring.

* refactor: Update reconnection timeout to enforce a strict 3-minute limit

* ci: Update reconnection timeout tests to reflect a strict 3-minute limit

* ci: Update reconnection timeout tests to enforce a strict 3-minute limit

* chore: Remove unused MCP connection timeout message
2025-09-21 22:58:19 -04:00
Danny Avila
f0599ad36c 🧬 refactor: Optimize MCP Tool Queries with Server-Centric Architecture
🧬 refactor: Optimize MCP Tool Queries with Server-Centric Architecture

refactor: optimize mcp tool queries by removing redundancy, making server-centric structure, enabling query only when expected, minimize looping/transforming query data, eliminating unused/compute-heavy methods

ci: MCP Server Tools Mocking in Agent Tests
2025-09-21 20:40:14 -04:00
Danny Avila
5b1a31ef4d 🔄 refactor: Optimize MCP Tool Initialization
🔄 refactor: Optimize MCP Tool Initialization

fix: update tool caching to use separated mcp logic

refactor: Replace `req.user` with `userId` in MCP handling functions

refactor: Replace `req` parameter with `userId` in file search tool functions

fix: Update user connection parameter to use object format in reinitMCPServer

refactor: Simplify MCP tool creation logic and improve handling of tool configurations to avoid capturing too much in closures

refactor: ensure MCP available tools are fetched from cache only when needed
2025-09-21 20:31:28 -04:00
Danny Avila
386900fb4f 🧰 refactor: Decouple MCP Tools from System Tools (#9748) 2025-09-21 07:56:40 -04:00
Federico Ruggi
9d2aba5df5 🛡️ fix: Handle Null MCPManager In OAuthReconnectionManager (#9740) 2025-09-20 11:06:23 -04:00
Thomas Joußen
a5195a57a4 🔐 fix: Handle Multiple Email Addresses in LDAP Auth (#9729) 2025-09-20 11:01:45 -04:00
Danny Avila
2489670f54 📂 refactor: File Read Operations (#9747)
* fix: axios response logging for text parsing, remove console logging, remove jsdoc

* refactor: error logging in logAxiosError function to handle various error types with type guards

* refactor: enhance text parsing with improved error handling and async file reading

* refactor: replace synchronous file reading with asynchronous methods for improved performance and memory management

* ci: update tests
2025-09-20 10:17:24 -04:00
Danny Avila
0352067da2 🎨 refactor: Improve Mermaid Artifacts Styling (#9742)
* 🎨 refactor: Improve Mermaid Artifacts Styling

* refactor: Replace ArtifactMarkdown with MermaidMarkdown
2025-09-20 08:19:44 -04:00
Danny Avila
fcaf55143d 🏷️ fix: Increment Tag Counters When Forking/Duplicating Conversations (#9737)
* fix: increment tag counters when forking/duplicating conversations

- Add bulkIncrementTagCounts to update existing tag counts in bulk
- Integrate tag count updates into importBatchBuilder.saveBatch() using Promise.all
- Update frontend mutations to directly update cache instead of invalidating queries
- Optimize bulkIncrementTagCounts to skip unnecessary database queries

Fixes issue where forked/duplicated conversations with bookmarks would not increment
tag counters, leading to negative counts when bookmarks were later removed.

* chore: reorder import statements for clarity in fork.spec.js
2025-09-19 22:02:09 -04:00
Danny Avila
aae3694b11 🛠️ chore: Typing and Remove Comments (#9732)
* chore: Update documentation for formatToolContent function, remove JSDoc types and duplicate comments

* chore: fix type errors due to attachment.filename in Attachment component
2025-09-19 16:22:53 -04:00
github-actions[bot]
68c9f668c1 🌍 i18n: Update translation.json with latest translations (#9726)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 13:26:57 -04:00
Danny Avila
8b2e1c6088 📝 fix: Prevent Deletion of User Input During AI Generation (#9719)
* bug: updating so text doesn't delete

* fix: preserve user input when typing during AI response

Previously, form.reset() would clear any text the user started typing while waiting for AI response. Now checks if textarea has new content before resetting, preventing text loss and improving UX.

* chore: revert useSubmitMessage changes

* fix: reduce debounce time for input handling in auto-save

* fix: improve debounce handling for auto-save input to enhance user experience

---------

Co-authored-by: Megan Greenberg <mgreenberg@networkninja.com>
2025-09-19 13:19:38 -04:00
Danny Avila
99135a3dc1 🔍 fix: Race Condition in Search Bar Clear Text Handler (#9718) 2025-09-19 07:52:16 -04:00
Danny Avila
344e7c44b5 🔐 fix: Respect Server's Token Endpoint Auth Methods for MCP OAuth Refresh (#9717)
* fix: respect server's token endpoint auth methods for MCP OAuth refresh

Previously, LibreChat always used Basic Auth when refreshing OAuth tokens if a
client_secret was present. This caused issues with servers (like FastMCP) that
only support client_secret_post. Now properly checks and respects the server's
advertised token_endpoint_auth_methods_supported.

Fixes token refresh failures with error: "refresh_token.client_id: Field required"

* chore: remove MCP OAuth URL Logging
2025-09-19 06:50:02 -04:00
github-actions[bot]
e5d2a932bc 🌍 i18n: Update translation.json with latest translations (#9704)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 06:06:40 -04:00
Danny Avila
c40554c03b 🏪 fix: Template for Chats Starting from Agent Marketplace (#9702)
* fix: correctly build conversation template and preset for chats starting from marketplace

* test: enhance AgentDetail tests with additional localization and conversation mocks
2025-09-18 21:05:43 -04:00
Danny Avila
98af4564e8 🎯 refactor: MCP Registry To Handle serverInstructions As String "true" (#9703)
- Updated MCPServersRegistry to correctly process serverInstructions when provided as a string "true", allowing it to fetch instructions from the server.
- Added a new utility function `isEnabled` to determine if serverInstructions should trigger a fetch.
- Introduced comprehensive tests to validate the behavior for different serverInstructions configurations, ensuring both string "true" and boolean true fetch from the server while custom strings remain unchanged.

🎯 This enhancement improves the flexibility and correctness of server instruction handling in the MCPServersRegistry.
2025-09-18 21:03:21 -04:00
Real Null
26a58fcabc 🚨 fix: Redis CA file handling (#9692)
* 🚨 fix: Critical Redis CA file handling bug that could crash app

🔧 Added safe error handling for Redis CA certificate file reading in cacheConfig.js

## 🐛 Problem
- fs.readFileSync() was called directly without error handling
- Missing or inaccessible REDIS_CA files would throw unhandled exceptions
- 💥 Application would crash during startup with cryptic filesystem errors
-  No validation of file existence before attempting to read

##  Solution
-  Added getRedisCA() helper function with comprehensive error handling
- 🔍 Implemented fs.existsSync() check before file reading attempts
- 🛡️ Added try-catch block to handle filesystem errors gracefully
- 📝 Added informative warning/error logging for troubleshooting
- 🔄 Function returns null safely on any error condition

## 🎯 Benefits
- 🚫 Prevents application crashes from misconfigured CA certificate paths
- 🔍 Provides clear error messages for debugging certificate issues
-  Maintains backward compatibility for valid certificate configurations
- 🚀 Improves production stability and deployment reliability

## 🧪 Testing Results
-  Verified handling of missing REDIS_CA environment variable
-  Tested with non-existent file paths (returns null with warning)
-  Confirmed valid certificate files are read correctly
-  Validated error handling for permission/access issues

🎉 This fix ensures LibreChat continues running regardless of Redis CA
certificate configuration problems, improving overall system reliability.

🏷️ Type: 🐛 Bug Fix
📊 Impact: 🔴 High (prevents application crashes)
🎯 Area: Cache Configuration, Redis Integration

* chore: Redis CA certificate handling with proper logging + JSDocs

* chore: Improve error logging for Redis CA certificate file read failure

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-18 20:33:40 -04:00
Danny Avila
3fec63e597 💽 fix: Memory Permissions Handling (#9701) 2025-09-18 20:26:02 -04:00
Danny Avila
81139046e5 🔄 refactor: Convert OCR Tool Resource to Context (#9699)
* WIP: conversion of `ocr` to `context`

* refactor: make `primeResources` backwards-compatible for `ocr` tool_resources

* refactor: Convert legacy `ocr` tool resource to `context` in agent updates

- Implemented conversion logic to replace `ocr` with `context` in both incoming updates and existing agent data.
- Merged file IDs and files from `ocr` into `context` while ensuring deduplication.
- Updated tools array to reflect the change from `ocr` to `context`.

* refactor: Enhance context file handling in agent processing

- Updated the logic for managing context files by consolidating file IDs from both `ocr` and `context` resources.
- Improved backwards compatibility by ensuring that context files are correctly populated and handled.
- Simplified the iteration over context files for better readability and maintainability.

* refactor: Enhance tool_resources handling in primeResources

- Added tests to verify the deletion behavior of tool_resources fields, ensuring original objects remain unchanged.
- Implemented logic to delete `ocr` and `context` fields after fetching and re-categorizing files.
- Preserved context field when the context capability is disabled, ensuring correct behavior in various scenarios.

* refactor: Replace `ocrEnabled` with `contextEnabled` in AgentConfig

* refactor: Adjust legacy tool handling order for improved clarity

* refactor: Implement OCR to context conversion functions and remove original conversion logic in update agent handling

* refactor: Move contextEnabled declaration to maintain consistent order in capabilities

* refactor: Update localization keys for file context to improve clarity and accuracy

* chore: Update localization key for file context information to improve clarity
2025-09-18 20:06:59 -04:00
Danny Avila
89d12a8ccd 🔍 fix: Retrieve Multiple Agents In File Access Check (#9695)
- Implemented `getAgents` function to retrieve multiple agent documents based on search parameters.
- Updated `fileAccess` middleware to utilize `getAgents` instead of `getAgent` for improved file access checks.
- Added comprehensive tests for file access middleware, covering various scenarios including user permissions and agent ownership.
2025-09-18 15:42:05 -04:00
github-actions[bot]
f6d34d78ca 🌍 i18n: Update translation.json with latest translations (#9691)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-18 14:45:21 -04:00
Danny Avila
48ca1bfd88 🧩 refactor: File Upload Options based on Ephemeral Agent (#9693)
* refactor: agent tool permissions to support ephemeral agent settings

* ci: rename render tests and correct typing for `useAgentToolPermissions` hook

* refactor: implement `DragDropContext` to minimize effect of `useChatContext` in `DragDropModal`
2025-09-18 14:44:55 -04:00
Danny Avila
208be7c06c 🗨️ refactor: Only Allow Prompt Queries with Access (#9688) 2025-09-18 10:00:33 -04:00
Danny Avila
02bfe32905 🛠️ fix: Missing Tool Definitions on Redis Cache Clear (#9681) 2025-09-17 23:19:28 -04:00
github-actions[bot]
4499494aba 🌍 i18n: Update translation.json with latest translations (#9679)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-17 23:14:49 -04:00
Federico Ruggi
d04da60b3b 💫 feat: MCP OAuth Auto-Reconnect (#9646)
* add oauth reconnect tracker

* add connection tracker to mcp manager

* reconnect oauth mcp servers function

* call reconnection in auth controller

* make sure to check connection in panel

* wait for isConnected

* add const for poll interval

* add logging to tryReconnect

* check expiration

* check mcp manager is not null

* check mcp manager is not null

* add test for reconnecting mcp server

* unify logic inside OAuthReconnectionManager

* test reconnection manager, adjust

* chore: reorder import statements in index.js

* chore: imports

* chore: imports

* chore: imports

* chore: imports

* chore: imports

* chore: imports and use types explicitly

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-17 16:49:36 -04:00
keltschdt
0e94d97bfb fix: Disable TTL For Transient OIDC Users In Permission Service (#9643) 2025-09-17 14:21:36 -04:00
Danny Avila
45ab4d4503 🎋 refactor: Improve Message UI State Handling (#9678)
* refactor: `ExecuteCode` component with submission state handling and cancellation message

* fix: Remove unnecessary argument check for execute_code tool call

* refactor: streamlined messages context

* chore: remove unused Convo prop

* chore: remove unnecessary whitespace in Message component

* refactor: enhance message context with submission state and latest message tracking

* chore: import order
2025-09-17 13:07:56 -04:00
github-actions[bot]
0ceef12eea 🌍 i18n: Update translation.json with latest translations (#9648)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-15 18:41:34 -04:00
Danny Avila
6738360051 📋 refactor: Agent Tool Permissions for File Upload Options (#9647)
- Added isEphemeralAgent function to streamline checks for ephemeral agents.
- Updated logic in useAgentToolPermissions to utilize the new function for determining tool access.
- Introduced comprehensive tests for useAgentToolPermissions covering various scenarios including ephemeral agents, regular agents with tools, and edge cases.
2025-09-15 12:57:40 -04:00
Dustin Healy
52b65492d5 👻 fix: Phantom MCP Tool Calls (#9634)
* fix: mcp tool calls no longer happening when unselected (without breaking new convo behavior)

* refactor: Improve ephemeral agent synchronization logic in useMCPSelect

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-15 10:35:15 -04:00
Danny Avila
7a9a99d2a0 🔗 refactor: URL sanitization for MCP logging (#9632) 2025-09-14 18:55:32 -04:00
Danny Avila
5bfb06b417 💻 feat: Add Proxy Config for Mistral OCR API (#9629)
* 💻 feat: Add proxy configuration support for Mistral OCR API requests

* refactor: Implement proxy support for Mistral API requests using HttpsProxyAgent
2025-09-14 18:50:41 -04:00
github-actions[bot]
2ce8f1f686 🌍 i18n: Update translation.json with latest translations (#9626)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-14 18:48:17 -04:00
Danny Avila
1a47601533 🔃 fix: Refresh Token Edge Cases (#9625)
* 🔃 fix: Refresh Token Edge Cases

* chore: Update parameter type for setAuthTokens function
2025-09-13 21:36:45 -04:00
Danny Avila
5245aeea8f 🔧 refactor: Consolidate MCP tool removal and Improve UX (#9609)
* 🔧 refactor: Consolidate MCP tool removal and Improve UX

- Removed redundant tool removal logic from MCPTool, UnconfiguredMCPTool, and UninitializedMCPTool components.
- Introduced `useRemoveMCPTool` hook to handle tool removal and toast notifications.
- Updated translation.json to include a reminder message for saving changes after tool removal.

* chore: remove unused i18n key
2025-09-12 21:37:07 -04:00
Jesse Bye
dd93db40bc ⛑️ feat: Helm serviceAccount Config (#9606) 2025-09-12 17:37:29 -04:00
Muhammad Azhdari
136cf1d5a8 ⛑️ fix: follow postgres bitnami values schema in rag-api helm chart (#7782) 2025-09-12 17:36:38 -04:00
Danny Avila
751522087a v0.8.0-rc4 (#9601)
*  v0.8.0-rc4

* chore: update jest.config.cjs to include release comment and linting

* chore: bump CONFIG_VERSION to 1.2.9
2025-09-12 13:37:10 -04:00
github-actions[bot]
7fe830acfc 🌍 i18n: Update translation.json with latest translations (#9599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 13:00:30 -04:00
Danny Avila
cdfe686987 📦 chore: bump axios to v1.12.1 (#9600) 2025-09-12 13:00:12 -04:00
Danny Avila
5b5723343c 🔍 refactor: Preserve Category in Agent Marketplace Search (#9598) 2025-09-12 11:36:42 -04:00
Sebastien Bruel
30c24a66f6 🪟 fix: Auto-fetch agents to fill Viewport in Marketplace Scroll (#9591) 2025-09-12 10:59:15 -04:00
Christian Geisler
ecf9733bc1 🐳 fix: Add missing uploads directory to Dockerfile (#9590) 2025-09-12 10:56:14 -04:00
Danny Avila
133312fb40 🔐 fix: Remove OAuth handler cleanup at Connection Change (#9589) 2025-09-12 00:34:45 -04:00
github-actions[bot]
b62ffb533c 🌍 i18n: Update translation.json with latest translations (#9586)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 00:29:47 -04:00
Danny Avila
d75fb76338 refactor: Add Effective Timeout for MCP Fetch (#9585) 2025-09-11 19:09:13 -04:00
Danny Avila
51f2d43fed 🔐 refactor: Improve MCP OAuth Event Handler Cleanup (#9584)
* 🔐 refactor: Improve MCP OAuth event handling and cleanup

* ci: MCPConnection mock with additional event handling methods
2025-09-11 18:54:43 -04:00
Danny Avila
e3a645e8fb 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure (#9583)
* 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure

* chore: Update import for SearchResultData and fix FormattedToolResponse type build warning
2025-09-11 16:51:40 -04:00
Danny Avila
180046a3c5 ✂️ refactor: Artifacts and Tool Callbacks to Pass UI Resources (#9581)
* ✂️ refactor: use artifacts and callbacks to pass UI resources

* chore: imports

* refactor: Update UIResource type imports and definitions across components and tests

* refactor: Update ToolCallInfo test data structure and enhance TAttachment type definition

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
2025-09-11 14:34:07 -04:00
github-actions[bot]
916742ab9d 🌍 i18n: Update translation.json with latest translations (#9570)
* 🌍 i18n: Update translation.json with latest translations

* Update drag and drop tooltip text

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-11 08:13:43 -04:00
Danny Avila
d91f34dd42 🔒 refactor: Optimize Email Domain Validation in OpenID, SAML, and Social Logins (#9567)
* refactor: Optimize Email Domain Validation in OpenID, SAML, and Social Login Strategies

    - Implemented email domain validation for user authentication in OpenID and SAML strategies, ensuring only allowed domains are processed.
    - Adjusted error messages for clarity and consistency across authentication methods.
    - Refactored social login to validate email domains before checking for existing users, improving registration flow.

* refactor: Email Domain Validation in LDAP and Social Login Strategies
2025-09-11 01:01:58 -04:00
Danny Avila
5676976564 🔒 fix: Email Domain Validation Order and Coverage (#9566) 2025-09-10 23:13:39 -04:00
Danny Avila
85aa3e7d9c 🔧 refactor: Centralize Collection Checks for Permissions Migration (#9565)
* 🔧 refactor: Centralize Collection Existence Checks for Permissions Migration

* Replace individual collection existence checks with a unified function `ensureRequiredCollectionsExist` in the database utility module.
* Update migration scripts for agents and prompts to utilize the new function, ensuring all required collections are verified for existence in a single call.
* Remove redundant collection existence logic from migration files, improving code maintainability and clarity.

* chore: import order in migration scripts

* 🔧 test: Update Token Test Cases for Realistic Scenarios

* Changed email in test data to 'user1-alt@example.com' for a more realistic scenario.
* Clarified expectation comment for token retrieval to indicate it finds the only matching token based on criteria.
2025-09-10 20:40:58 -04:00
Dustin Healy
a2ff6613c5 🪄 fix: MCP UI Renders for OAuth and Custom User Vars Servers (#9559) 2025-09-10 19:02:30 -04:00
Theo N. Truong
8d6cb5eee0 🧹 chore: Remove Unused Cache Configuration Keys (#9551)
* Remove unused STATIC_CONFIG and LIBRECHAT_YAML_CONFIG cache keys.

These cache keys were identified as dead code - they were being written to but never read from anywhere in the codebase after a recent refactor:

- STATIC_CONFIG was used as a cache namespace that stored configuration data
- LIBRECHAT_YAML_CONFIG was the key used within that namespace to store parsed YAML config
- The cache.set() operation in loadCustomConfig.js stored the config but no cache.get() operations retrieved it
- Configuration data is already handled through other mechanisms without caching

* # removed tests regarding cache
2025-09-10 19:01:44 -04:00
Federico Ruggi
31445e391a 🔖 fix: Agent Marketplace Bookmark and New Chat buttons (#9549)
* don't require conversation for bookmark button

* wrap marketplace component so it can correctly use context hooks

* chore: re-order import statement for MarketplaceProvider

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-09-10 19:01:34 -04:00
Federico Ruggi
04c3a5a861 🔌 feat: Revoke MCP OAuth Credentials (#9464)
* revocation metadata fields

* store metadata

* get client info and meta

* revoke oauth tokens

* delete flow

* uninstall oauth mcp

* revoke button

* revoke oauth refactor, add comments, test

* adjust for clarity

* test deleteFlow

* handle metadata type

* no mutation

* adjust for clarity

* styling

* restructure for clarity

* move token-specific stuff

* use mcpmanager's oauth servers

* fix typo

* fix addressing of oauth prop

* log prefix

* remove debug log
2025-09-10 18:53:34 -04:00
Federico Ruggi
5667cc9702 🏪 fix: Show Agent Builder in Marketplace (#9537)
* don't require conversation endpoint

* bump up render time a bit

* a little less
2025-09-10 18:48:17 -04:00
Theo N. Truong
c0f95f971a 🗄️ refactor: Make APP_CONFIG a Dedicated Cache Store (#9558)
- This allows use APP_CONFIG in FORCED_IN_MEMORY_CACHE_NAMESPACES
- Remove the complexity of nested namespace (e.g. we no longer have to worry about the prefix of every role key)
2025-09-10 18:46:54 -04:00
Danny Avila
f125f5bd32 🤖 refactor: Auto-validate IDs in Agent Query (#9555)
* 🤖 refactor: Auto-validate IDs in Agent Query

* chore: remove comments in useAgentToolPermissions
2025-09-10 18:38:33 -04:00
Danny Avila
f3eca8c7a7 📦 chore: bump vite to address low severity vulns (#9553)
* 📦 chore: bump `vite` to address low severity vulns

* chore: update bun.lockb to reflect dependency changes
2025-09-10 14:56:46 -04:00
github-actions[bot]
f22e5f965e 🌍 i18n: Update translation.json with latest translations (#9533)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 14:29:33 -04:00
Danny Avila
749f539dfc 📬 refactor: Improved Rendering and Localization for Drag & Drop Files (#9547)
* 📬 refactor: Improved Rendering and Localization for Drag & Drop Files

- Refactored DragDropOverlay to use memoization and props for active state management.
- Updated the overlay to always render, reducing mount/unmount overhead.
- Improved user experience with localized text for drag-and-drop instructions.
- Enhanced file handling logic in useDragHelpers for better performance and clarity.

* fix: agent data retrieval in drag helper
2025-09-10 14:27:57 -04:00
Danny Avila
1247207afe 🔒 fix: Memory Disabled Config UI Permissions (2/2) 2025-09-09 22:00:01 -04:00
Danny Avila
5c0e9d8fbb 📂 refactor: Show File Search and Code File Upload Options Based on Agent Tools (#9532) 2025-09-09 20:48:29 -04:00
Dustin Healy
957fa7a994 😶‍🌫️ refactor: Conditionally Hide Tools Dropdown (#9530) 2025-09-09 19:57:50 -04:00
Danny Avila
751c2e1d17 👻 refactor: LocalStorage Cleanup and MCP State Optimization (#9528)
* 👻 refactor: MCP Select State with Jotai Atoms

* refactor: Implement timestamp management for ChatArea localStorage entries

* refactor: Integrate MCP Server Manager into BadgeRow context and components to avoid double-calling within BadgeRow

* refactor: add try/catch

* chore: remove comment
2025-09-09 17:32:10 -04:00
Danny Avila
519645c0b0 🔻 fix: Role and System Message Handling for ChatGPT Imports (#9524)
* fix: ChatGPT import logic breaks message graph when it encounters a system message

- Implemented `findNonSystemParent` to maintain parent-child relationships by skipping system messages.
- Added a test case to ensure system messages do not disrupt the conversation flow during import.

* fix: ChatGPT import, correct sender for user messages with GPT-4 model

* fix: Enhance model name extraction for assistant messages in import process

- Updated sender assignment logic to dynamically extract model names from model slugs, improving accuracy for various GPT models.
- Added comprehensive tests to validate the extraction and formatting of model names from different model slugs, ensuring robustness in the import functionality.
2025-09-09 13:51:26 -04:00
Danny Avila
0d0a318c3c 📦 chore: Update caniuse-lite to v1.0.30001741 (#9523) 2025-09-09 09:26:15 -04:00
Danny Avila
588e0c4611 🔒 fix: Memory Disabled Config UI Permissions (#9522) 2025-09-09 09:14:40 -04:00
github-actions[bot]
79144a6365 🌍 i18n: Update translation.json with latest translations (#9515)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 09:08:28 -04:00
Danny Avila
ca53c20370 🚃 refactor: Normalize paths for Vite Config Chunking (#9513) 2025-09-08 21:53:15 -04:00
Danny Avila
d635503f49 🔐 ci: Add MCP Environment Processing tests 2025-09-08 15:38:44 -04:00
Dev
920966f895 🔐 fix: Resolve Env. Variables for MCP OAuth Manual Config (#9501)
* Added functionality to process OAuth configuration within the MCP environment.
* Implemented handling for string values in OAuth settings, ensuring proper processing of environment variables.
* Maintained original structure for non-string values to preserve existing configurations.
2025-09-08 15:29:10 -04:00
Danny Avila
c46e0d3ecc 🔒 fix: href Attribute in Email Microsoft Template 2025-09-08 14:39:00 -04:00
Dustin Healy
c6ecf0095b 🎚️ feat: Anthropic Parameter Set Support via Custom Endpoints (#9415)
* refactor: modularize openai llm config logic into new getOpenAILLMConfig function (#9412)

* ✈️ refactor: Migrate Anthropic's getLLMConfig to TypeScript (#9413)

* refactor: move tokens.js over to packages/api and update imports

* refactor: port tokens.js to typescript

* refactor: move helpers.js over to packages/api and update imports

* refactor: port helpers.js to typescript

* refactor: move anthropic/llm.js over to packages/api and update imports

* refactor: port anthropic/llm.js to typescript with supporting types in types/anthropic.ts and updated tests in llm.spec.js

* refactor: move llm.spec.js over to packages/api and update import

* refactor: port llm.spec.js over to typescript

* 📝  Add Prompt Parameter Support for Anthropic Custom Endpoints (#9414)

feat: add anthropic llm config support for openai-like (custom) endpoints

* fix: missed compiler / type issues from addition of getAnthropicLLMConfig

* refactor: update tokens.ts to export constants and functions, enhance type definitions, and adjust default values

* WIP: first pass, decouple `llmConfig` from `configOptions`

* chore: update import path for OpenAI configuration from 'llm' to 'config'

* refactor: enhance type definitions for ThinkingConfig and update modelOptions in AnthropicConfigOptions

* refactor: cleanup type, introduce openai transform from alt provider

* chore: integrate removeNullishValues in Google llmConfig and update OpenAI exports

* chore: bump version of @librechat/api to 1.3.5 in package.json and package-lock.json

* refactor: update customParams type in OpenAIConfigOptions to use TConfig['customParams']

* refactor: enhance transformToOpenAIConfig to include fromEndpoint and improve config extraction

* refactor: conform userId field for anthropic/openai, cleanup anthropic typing

* ci: add backward compatibility tests for getOpenAIConfig with various endpoints and configurations

* ci: replace userId with user in clientOptions for getLLMConfig

* test: add Azure OpenAI endpoint tests for various configurations in getOpenAIConfig

* refactor: defaultHeaders retrieval for prompt caching for anthropic-based custom endpoint (litellm)

* test: add unit tests for getOpenAIConfig with various Anthropic model configurations

* test: enhance Anthropic compatibility tests with addParams and dropParams handling

* chore: update @librechat/agents dependency to version 2.4.78 in package.json and package-lock.json

* chore: update @librechat/agents dependency to version 2.4.79 in package.json and package-lock.json

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-08 14:35:29 -04:00
Danny Avila
7de6f6e44c ⚙️ chore: Update Build Config due to Windows Tests (#9511)
* chore: remove `rollup-plugin-generate-package-json`

* chore: increase maximum file size to cache in Vite configuration for windows builds
2025-09-08 14:16:49 -04:00
Danny Avila
035f85c3ba 🧪 ci: Tests for Anthropic and OpenAI LLM Configuration (#9484)
* fix: freq. and pres. penalty use camelcase

* ci: OpenAI Configuration Tests

* ci: Enhance OpenAI Configuration Tests with Azure and Custom Endpoint Scenarios

* Added integration tests for OpenAI and Azure configurations simulating various initialization scenarios.
* Updated OpenAIConfigOptions to allow null values for reverseProxyUrl and proxy.
* Improved handling of reasoning parameters in tests for both OpenAI and Azure setups.
* Ensured robust error handling for missing API keys and malformed configurations.
* Optimized performance for large parameter sets in configuration.

* test: Add comprehensive integration tests for Anthropic LLM configuration

* Introduced real usage integration tests for various Anthropic endpoint configurations, including handling of proxy and reverse proxy setups.
* Implemented model-specific scenarios for Claude-3.7 and web search functionality.
* Enhanced error handling for missing user IDs and large parameter sets.
* Validated parameter logic, including default values, boundary conditions, and type handling for numeric and array parameters.
* Ensured proper exclusion of system options from model options and maintained expected behavior across different model variations.
2025-09-06 09:42:12 -04:00
Daniel Andersen
6f6a34d126 🔗 feat: Custom Jina API URL for Web Search Reranking (#9236)
* feat: added support for custom JINA_API_URL

* fixed tests

* chore: Update @librechat/agents dependency to version 2.4.77 in package-lock.json and package.json files

* fix: Update Jina API URL to use environment variable in configuration files

* Refactor AppService, web.ts, and config.ts to replace hardcoded Jina API URL with an environment variable placeholder.
* Ensure consistency across tests and configuration for Jina API URL.

* chore: alphabetical order translation.json

* fix: alphabetical order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-06 08:39:20 -04:00
Danny Avila
fff1f1cf27 🔒 fix: Update Token Deletion To Prevent Undefined Field Queries (#9477)
* Refactor deleteTokens to use an array of conditions for querying, ensuring only specified fields are considered for deletion.
* Add error handling to prevent accidental deletion when no query parameters are provided.
* Update AuthService to match the new deleteTokens signature by passing an object instead of a string for email.
2025-09-05 17:26:02 -04:00
Danny Avila
1869854d70 🌐 fix: Prevent MCP Body/Header Timeouts at 5-Minute mark (#9476)
* chore: improve error log for tool error

* fix: add undici as fetch method with agent to prevent body/header timeouts at 5-minute mark
2025-09-05 17:14:39 -04:00
github-actions[bot]
4dd2998592 🌍 i18n: Update translation.json with latest translations (#9473)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-05 16:59:11 -04:00
Danny Avila
a4a174b3dc 🛠️ refactor: Only Show Agents MCP UI When Configured (#9471) 2025-09-05 12:28:00 -04:00
Danny Avila
65c83317aa 🗣️ feat: Language Support for OpenAI Speech-to-Text (#9470) 2025-09-05 12:01:00 -04:00
Sebastien Bruel
e95e0052da 🗄️ feat: Allow Skipping Transactions When Balance is Disabled (#9419)
* Disable transaction creation when balance is disabled

* Add configuration to disable transactions creation

* chore: remove comments

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-09-05 11:21:02 -04:00
Danny Avila
0ecafcd38e 🔢 feat: Add Support for Integer and Float JSON Schema Types (#9469)
* 🔧 fix: Extend JsonSchemaType to include 'integer' and 'float' types

* ci: tests for new integer/float types
2025-09-05 11:12:44 -04:00
Pranshu Mahajan
cadfe14abe ⚙️ fix: Dynamic HPA API Version Selection for K8s Compatibility (#9320)
Co-authored-by: Pranshu Mahajan <pranshu.mahajan#foxtel.com.au>
2025-09-05 11:11:51 -04:00
Danny Avila
75dd6fb28b 🛂 refactor: Centralize fileStrategy Resolution for OpenID, SAML, and Social Logins (#9468)
* 🔑 refactor: `fileStrategy` for OpenID, SAML, and Social logins

* ci: Update Apple strategy tests to use correct isEnabled import and enhance handleExistingUser call
2025-09-05 11:09:32 -04:00
Ben Verhees
eef93024d5 🔍 fix: Display File Search Citations Based on Permissions (#9454)
* Make file search citations conditional

* refactor: improve permission handling to avoid redundant checks by including it in artifact

* chore: reorder imports for better organization and clarity

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-05 09:14:55 -04:00
Danny Avila
cd73cb0b3e 🔐 fix: Image Validation when Reusing OpenID Token (#9458)
* 🔧 fix: Enhance OpenID token handling with user ID for image path validation

* 🔧 fix: Change logger level to error for user info fetch failure and remove redundant info log in OpenID user lookup

* 🔧 refactor: Remove validateImageRequest from middleware exports and enhance validation logic in validateImageRequest.js

* Removed validateImageRequest from the middleware index.
* Improved error handling and validation checks in validateImageRequest.js, including handling of OpenID tokens, URL length, and malformed URLs.
* Updated tests in validateImages.spec.js to cover new validation scenarios and edge cases.
2025-09-05 03:12:17 -04:00
github-actions[bot]
e705b09280 🌍 i18n: Update translation.json with latest translations (#9439)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-03 12:02:07 -04:00
Danny Avila
23bd4dfbfd 🔧 fix: Handle Missing MCP Config Gracefully in Config/Plugin Routes (#9438)
* 🛠️ fix: Update Plugins and Config Routes to Handle No MCP Config

* refactor: Rename cachedMCPPlugins to mcpPlugins for clarity in PluginController
2025-09-03 11:58:39 -04:00
github-actions[bot]
df17582103 🌍 i18n: Update translation.json with latest translations (#9434)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-03 03:05:36 -04:00
Danny Avila
d79b80a4bf 📜 chore: Remove debug log for request headers in MCPConnection 2025-09-03 03:01:39 -04:00
Danny Avila
45da421e7d 🦾 refactor: filter Model Specs based on user access to Agents (#9433) 2025-09-03 02:59:57 -04:00
Eduardo Cruz Guedes
122ff416ac 🌒 refactor: Theme Handling to use isDark Utility (#9405)
*  fix: Refactor theme handling to use isDark utility across components

* 🔧 fix: Update package client version to 0.2.8 and adjust theme import path in ThemeSelector component

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2025-09-03 02:56:36 -04:00
github-actions[bot]
b66bf93b31 🌍 i18n: Update translation.json with latest translations (#9381)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-03 02:21:38 -04:00
Samuel Path
6d791e3e12 🚦 feat: Simplify MCP UI integration and add unit tests (#9418) 2025-09-03 02:21:12 -04:00
Michael Forman
f9b12517b0 🌟 fix: Add Composite Indexes to Agent Categories for CosmosDB Compatibility (#9430) 2025-09-03 02:16:18 -04:00
Joseph Licata
195e1e9eb2 ⬆️ refactor: Enable File Search from Upload Option (#9425) 2025-09-03 02:08:48 -04:00
Danny Avila
47aa90df1d 📦 chore: Update data-schemas to 0.0.21 and update IUser plugins type 2025-08-30 23:20:22 -04:00
Danny Avila
460eac36f6 🗨️ fix: Prompts Pagination (#9385)
* 🗨️ fix: Prompts Pagination

* ci: Simplify user middleware setup in prompt tests
2025-08-30 15:58:49 -04:00
Sebastien Bruel
3a47deac07 📋 feat: Support Custom Content-Types in Action Descriptors (#9364) 2025-08-29 23:02:40 -04:00
Dustin Healy
49e8443ec5 ✂️ refactor: MCP UI Separation for Agents (#9237)
* refactor: MCP UI Separation for Agents (Dustin WIP)

feat: separate MCPs into their own lists away from tools + actions and add the status indicator functionality from chat to their dropdown ui

fix: spotify mcp was not persisting on agent creation

feat: show disconnected saved servers and their tools in agent mcp list in created agents

fix: select-all regression fixed (caused by deleting tools we were drawing from for rendering list)

fix: dont show all mcps, only those installed in agent in list

feat: separate ToolSelectDialog for MCPServerTools

fix: uninitialized mcp servers not showing as added in toolselectdialog

refactor: reduce looping in AgentPanelContext for categorizing groups and mcps

refactor: split ToolSelectDialog and MCPToolSelectDialog functionality (still needs customization for custom user vars)

chore: address ESLint comments

chore: address ESLint comments

feat: one-click initialization on MCP servers in agent builder

fix: stop propagation triggering reinit on caret click

refactor: split uninitialized MCPs component from initialized MCPs

feat: new mcp tool select dialog ui with custom user vars

feat: show initialization state for CUV configurable MCPs too

chore: remove unused localization string

fix: deselecting all tools caused a re-render

fix: remove subtools so removal from MCPToolSelectDialog works more consistently

feat: added servers have all tools enabled by default

feat: mcp server list now alphabetical to prevent annoying ui behavior of servers jumping around depending on tool selection

fix: filter out placeholder group mcp tools from any actual tool calls / definitions

feat: indicator now takes you to config dialog for uninitialized servers

feat: show previously configured mcp servers that are now missing from the yaml

feat: select all enabled by default on first add to mcp server list

chore: address ESLint comments

* refactor: MCP UI Separation for Agents (Danny WIP)

chore: remove use of `{serverName}_mcp_{serverName}`

chore: import order

WIP: separate component concerns

refactor: streamline agent mcp tools

refactor: unify MCP server handling and improve tool visibility logic, remove unnecessary normalization or sorting, remove nesting button, make variable names clear

refactor: rename mcpServerIds to mcpServerNames for clarity and consistency across components

refactor: remove groupedMCPTools and toolToServerMap, streamline MCP server handling in context and components to effectively utilize mcpServersMap

refactor: optimize tool selection logic by replacing array includes with Set for improved performance

chore: add error logging for failed auth URL parsing in ToolCall component

refactor: enhance MCP tool handling by improving server name management and updating UI elements for better clarity

* refactor: decouple connection status from useMCPServerManager with useMCPConnectionStatus

* fix: improve MCP tool validation logic to handle unconfigured servers

* chore: enhance log message clarity for MCP server disconnection in updateUserPluginsController

* refactor: simplify connection status extraction in useMCPConnectionStatus hook

* refactor: improve initializing UX

* chore: replace string literal with ResourceType constant in useResourcePermissions

* refactor: cleanup code, remove redundancies, rename variables for clarity

* chore: add back filtering and sorting for mcp tools dialog

* refactor: initializeServer to return response and early return

* refactor: enhance server initialization logic and improve UI for OAuth interaction

* chore: clarify warning message for unconfigured MCP server in handleTools

* refactor: prevent CustomUserVarsSection from submitting tools dialog form

* fix: nested button of button issue in UninitializedMCPTool

* feat: add functionality to revoke custom user variables in MCPToolSelectDialog

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-08-29 22:57:01 -04:00
Samuel Path
d16f93b5f7 🎨 feat: MCP UI basic integration (#9299) 2025-08-29 13:07:19 -04:00
Danny Avila
20b29bbfa6 🗺️ fix: Embedded file handling to use Proper Filename (#9372) 2025-08-29 12:23:18 -04:00
Danny Avila
e2a6937ca6 ⚙️ fix: Update OCR context to use req.config (#9367) 2025-08-29 10:06:03 -04:00
github-actions[bot]
005a0cb84a 🌍 i18n: Update translation.json with latest translations (#9361)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-29 08:49:18 -04:00
owengo
beabe38311 🖼️ fix: Resolve appConfig Access Before Initialization in Image Generation (#9366)
Co-authored-by: Olivier Schiavo <olivier.schiavo@wengo.com>
2025-08-29 08:47:12 -04:00
Danny Avila
62315be197 🔧 fix: Add missing configMiddleware to Convo Import Routes 2025-08-28 23:12:58 -04:00
Danny Avila
a26597a696 📇 refactor: Improve State mgmt. for File uploads and Tool Auth (#9359)
* 🔧 fix: Ensure loading state is correctly set when files are empty or in progress

* 🔧 fix: Update ephemeral agent state on file upload error for execute code tool resource

* 🔧 fix: Reset ephemeral agent state for tool when authentication fails

* refactor: Pass conversation prop to FileFormChat and AttachFileChat components
2025-08-28 23:11:16 -04:00
Danny Avila
8772b04d1d 🗃️ refactor: File Access via Agent; Deny Deletion if not Editor, Allow Viewer (#9357) 2025-08-28 21:16:23 -04:00
Danny Avila
7742b18c9c 🔧 fix: Upload Audio as Text missing Param (#9356) 2025-08-28 21:07:30 -04:00
Arthur Barrett
b75b799e34 🔧 fix: Handle Web API Streams in File Download Route for OpenAI Assistants (#9200) 2025-08-28 12:39:35 -04:00
Danny Avila
43add11b05 🎯 refactor: Custom Endpoint Request-based Header Resolution (#9344)
* refactor: resolve request-based headers for custom endpoints right before LLM request

* ci: clarify request-based header resolution in initializeClient test
2025-08-28 12:33:08 -04:00
Danny Avila
1764de53a5 fix: use appConfig correctly in getVoices 2025-08-28 00:51:22 -04:00
Danny Avila
c0511b9a5f 🔧 fix: MCP Selection Persist and UI Flicker Issues (#9324)
* refactor: useMCPSelect

    - Add useGetMCPTools to use in useMCPSelect and elsewhere hooks for fetching MCP tools
    - remove memoized key
    - remove use of `useChatContext` and require conversationId as prop

* feat: Add MCPPanelContext and integrate conversationId as prop for useMCPSelect across components

- Introduced MCPPanelContext to manage conversationId state.
- Updated MCPSelect, MCPSubMenu, and MCPConfigDialog to accept conversationId as a prop.
- Modified ToolsDropdown and BadgeRow to pass conversationId to relevant components.
- Refactored MCPPanel to utilize MCPPanelProvider for context management.

* fix: remove nested ternary in ServerInitializationSection

- Replaced conditional operator with if-else statements for better readability in determining button text based on server initialization state and reinitialization status.

* refactor: wrap setValueWrap in useCallback for performance optimization

* refactor: streamline useMCPSelect by consolidating storageKey definition

* fix: prevent clearing selections on page refresh by tracking initial load completion

* refactor: simplify concern of useMCPSelect hook

* refactor: move ConfigFieldDetail interface to common types for better reusability, isolate usage of `useGetMCPTools`

* refactor: integrate mcpServerNames into BadgeRowContext and update ToolsDropdown and MCPSelect components
2025-08-28 00:44:49 -04:00
Danny Avila
2483623c88 🔧 fix: type checking for process.browser in api-endpoints.ts 2025-08-27 20:27:57 -04:00
Danny Avila
229d6f2dfe 📦 chore: Update librechat-data-provider to v0.8.006 2025-08-27 20:23:18 -04:00
github-actions[bot]
d5ec838218 🌍 i18n: Update translation.json with latest translations (#9321)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-27 20:15:38 -04:00
418 changed files with 27501 additions and 6078 deletions

View File

@@ -690,8 +690,8 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys (e.g., STATIC_CONFIG,ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=STATIC_CONFIG,ROLES
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
#==================================================#
# Others #

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc3
# v0.8.0-rc4
# Base node image
FROM node:20-alpine AS node
@@ -30,7 +30,7 @@ RUN \
# Allow mounting of these files, which have no default
touch .env ; \
# Create directories for the volumes to inherit the correct permissions
mkdir -p /app/client/public/images /app/api/logs ; \
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
@@ -44,8 +44,6 @@ RUN \
npm prune --production; \
npm cache clean --force
RUN mkdir -p /app/client/public/images /app/api/logs
# Node API setup
EXPOSE 3080
ENV HOST=0.0.0.0

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.0-rc3
# v0.8.0-rc4
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -75,6 +75,7 @@
- 🔍 **Web Search**:
- Search the internet and retrieve relevant information to enhance your AI context
- Combines search providers, content scrapers, and result rerankers for optimal results
- **Customizable Jina Reranking**: Configure custom Jina API URLs for reranking services
- **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
- 🪄 **Generative UI with Code Artifacts**:

View File

@@ -10,7 +10,17 @@ const {
validateVisionModel,
} = require('librechat-data-provider');
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api');
const {
Tokenizer,
createFetch,
matchModelName,
getClaudeHeaders,
getModelMaxTokens,
configureReasoning,
checkPromptCacheSupport,
getModelMaxOutputTokens,
createStreamEventHandlers,
} = require('@librechat/api');
const {
truncateText,
formatMessage,
@@ -19,12 +29,6 @@ const {
parseParamFromPrompt,
createContextHandlers,
} = require('./prompts');
const {
getClaudeHeaders,
configureReasoning,
checkPromptCacheSupport,
} = require('~/server/services/Endpoints/anthropic/helpers');
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { sleep } = require('~/server/utils');

View File

@@ -1,4 +1,5 @@
const { google } = require('googleapis');
const { getModelMaxTokens } = require('@librechat/api');
const { concat } = require('@langchain/core/utils/stream');
const { ChatVertexAI } = require('@langchain/google-vertexai');
const { Tokenizer, getSafetySettings } = require('@librechat/api');
@@ -21,7 +22,6 @@ const {
} = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { spendTokens } = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const {

View File

@@ -7,7 +7,9 @@ const {
createFetch,
resolveHeaders,
constructAzureURL,
getModelMaxTokens,
genAzureChatCompletion,
getModelMaxOutputTokens,
createStreamEventHandlers,
} = require('@librechat/api');
const {
@@ -31,13 +33,13 @@ const {
titleInstruction,
createContextHandlers,
} = require('./prompts');
const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { addSpaceIfNeeded, sleep } = require('~/server/utils');
const { spendTokens } = require('~/models/spendTokens');
const { handleOpenAIErrors } = require('./tools/util');
const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains');
const { extractBaseURL } = require('~/utils');
const { tokenSplit } = require('./document');
const BaseClient = require('./BaseClient');
const { createLLM } = require('./llm');

View File

@@ -1,5 +1,5 @@
const { getModelMaxTokens } = require('@librechat/api');
const BaseClient = require('../BaseClient');
const { getModelMaxTokens } = require('../../../utils');
class FakeClient extends BaseClient {
constructor(apiKey, options = {}) {

View File

@@ -68,18 +68,19 @@ const primeFiles = async (options) => {
/**
*
* @param {Object} options
* @param {ServerRequest} options.req
* @param {string} options.userId
* @param {Array<{ file_id: string; filename: string }>} options.files
* @param {string} [options.entity_id]
* @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
* @returns
*/
const createFileSearchTool = async ({ req, files, entity_id }) => {
const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = false }) => {
return tool(
async ({ query }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
}
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
}
@@ -142,9 +143,9 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
const formattedString = formattedResults
.map(
(result, index) =>
`File: ${result.filename}\nAnchor: \\ue202turn0file${index} (${result.filename})\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${
result.content
}\n`,
`File: ${result.filename}${
fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
)
.join('\n---\n');
@@ -158,12 +159,14 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
}));
return [formattedString, { [Tools.file_search]: { sources } }];
return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
},
{
name: Tools.file_search,
responseFormat: 'content_and_artifact',
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.${
fileCitations
? `
**CITE FILE SEARCH RESULTS:**
Use anchor markers immediately after statements derived from file content. Reference the filename in your text:
@@ -171,7 +174,9 @@ Use anchor markers immediately after statements derived from file content. Refer
- Page reference: "According to report.docx... \\ue202turn0file1"
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`,
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
: ''
}`,
schema: z.object({
query: z
.string()

View File

@@ -1,9 +1,16 @@
const { logger } = require('@librechat/data-schemas');
const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
const { mcpToolPattern, loadWebSearchAuth, checkAccess } = require('@librechat/api');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const {
Tools,
Constants,
Permissions,
EToolResources,
PermissionTypes,
replaceSpecialVars,
} = require('librechat-data-provider');
const {
availableTools,
manifestToolMap,
@@ -26,7 +33,8 @@ const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSe
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { getMCPServerTools } = require('~/server/services/Config');
const { getRoleByName } = require('~/models/Role');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@@ -242,7 +250,6 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
const requestedMCPTools = {};
for (const tool of tools) {
@@ -281,7 +288,29 @@ const loadTools = async ({
if (toolContext) {
toolContextMap[tool] = toolContext;
}
return createFileSearchTool({ req: options.req, files, entity_id: agent?.id });
/** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
let fileCitations;
if (fileCitations == null && options.req?.user != null) {
try {
fileCitations = await checkAccess({
user: options.req.user,
permissionType: PermissionTypes.FILE_CITATIONS,
permissions: [Permissions.USE],
getRoleByName,
});
} catch (error) {
logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
fileCitations = false;
}
}
return createFileSearchTool({
userId: user,
files,
entity_id: agent?.id,
fileCitations,
});
};
continue;
} else if (tool === Tools.web_search) {
@@ -310,36 +339,34 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
});
};
continue;
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
} else if (tool && mcpToolPattern.test(tool)) {
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_all) {
const currentMCPGenerator = async (index) =>
createMCPTools({
req: options.req,
res: options.res,
index,
serverName,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
requestedMCPTools[serverName] = [currentMCPGenerator];
if (toolName === Constants.mcp_server) {
/** Placeholder used for UI purposes */
continue;
}
const currentMCPGenerator = async (index) =>
createMCPTool({
index,
req: options.req,
res: options.res,
toolKey: tool,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) {
logger.warn(
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
);
continue;
}
if (toolName === Constants.mcp_all) {
requestedMCPTools[serverName] = [
{
type: 'all',
serverName,
},
];
continue;
}
requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
requestedMCPTools[serverName].push(currentMCPGenerator);
requestedMCPTools[serverName].push({
type: 'single',
toolKey: tool,
serverName,
});
continue;
}
@@ -382,24 +409,64 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
const mcpToolPromises = [];
/** MCP server tools are initialized sequentially by server */
let index = -1;
for (const [serverName, generators] of Object.entries(requestedMCPTools)) {
const failedMCPServers = new Set();
for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
index++;
for (const generator of generators) {
/** @type {LCAvailableTools} */
let availableTools;
for (const config of toolConfigs) {
try {
if (generator && generators.length === 1) {
if (failedMCPServers.has(serverName)) {
continue;
}
const mcpParams = {
res: options.res,
userId: user,
index,
serverName: config.serverName,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
};
if (config.type === 'all' && toolConfigs.length === 1) {
/** Handle async loading for single 'all' tool config */
mcpToolPromises.push(
generator(index).catch((error) => {
createMCPTools(mcpParams).catch((error) => {
logger.error(`Error loading ${serverName} tools:`, error);
return null;
}),
);
continue;
}
const mcpTool = await generator(index);
if (!availableTools) {
try {
availableTools = await getMCPServerTools(serverName);
} catch (error) {
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
}
}
/** Handle synchronous loading */
const mcpTool =
config.type === 'all'
? await createMCPTools(mcpParams)
: await createMCPTool({
...mcpParams,
availableTools,
toolKey: config.toolKey,
});
if (Array.isArray(mcpTool)) {
loadedTools.push(...mcpTool);
} else if (mcpTool) {
loadedTools.push(mcpTool);
} else {
failedMCPServers.add(serverName);
logger.warn(
`MCP tool creation failed for "${config.toolKey}", server may be unavailable or unauthenticated.`,
);
}
} catch (error) {
logger.error(`Error loading MCP tool for server ${serverName}:`, error);

View File

@@ -1,4 +1,5 @@
const fs = require('fs');
const { logger } = require('@librechat/data-schemas');
const { math, isEnabled } = require('@librechat/api');
const { CacheKeys } = require('librechat-data-provider');
@@ -34,13 +35,35 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
}
}
/** Helper function to safely read Redis CA certificate from file
* @returns {string|null} The contents of the CA certificate file, or null if not set or on error
*/
const getRedisCA = () => {
const caPath = process.env.REDIS_CA;
if (!caPath) {
return null;
}
try {
if (fs.existsSync(caPath)) {
return fs.readFileSync(caPath, 'utf8');
} else {
logger.warn(`Redis CA certificate file not found: ${caPath}`);
return null;
}
} catch (error) {
logger.error(`Failed to read Redis CA certificate file '${caPath}':`, error);
return null;
}
};
const cacheConfig = {
FORCED_IN_MEMORY_CACHE_NAMESPACES,
USE_REDIS,
REDIS_URI: process.env.REDIS_URI,
REDIS_USERNAME: process.env.REDIS_USERNAME,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null,
REDIS_CA: getRedisCA(),
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '',
REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40),
REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0),

View File

@@ -157,12 +157,11 @@ describe('cacheConfig', () => {
describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => {
test('should parse comma-separated cache keys correctly', () => {
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, STATIC_CONFIG ,MESSAGES ';
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES ';
const { cacheConfig } = require('./cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([
'ROLES',
'STATIC_CONFIG',
'MESSAGES',
]);
});

View File

@@ -31,8 +31,8 @@ const namespaces = {
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),

View File

@@ -1,6 +1,6 @@
const { MCPManager, FlowStateManager } = require('@librechat/api');
const { EventSource } = require('eventsource');
const { Time } = require('librechat-data-provider');
const { MCPManager, FlowStateManager, OAuthReconnectionManager } = require('@librechat/api');
const logger = require('./winston');
global.EventSource = EventSource;
@@ -26,4 +26,6 @@ module.exports = {
createMCPManager: MCPManager.createInstance,
getMCPManager: MCPManager.getInstance,
getFlowStateManager,
createOAuthReconnectionManager: OAuthReconnectionManager.createInstance,
getOAuthReconnectionManager: OAuthReconnectionManager.getInstance,
};

View File

@@ -11,7 +11,7 @@ const {
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getCachedTools } = require('~/server/services/Config');
const { getMCPServerTools } = require('~/server/services/Config');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@@ -49,6 +49,14 @@ const createAgent = async (agentData) => {
*/
const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean();
/**
* Get multiple agent documents based on the provided search parameters.
*
* @param {Object} searchParameter - The search parameters to find agents.
* @returns {Promise<Agent[]>} Array of agent documents as plain objects.
*/
const getAgents = async (searchParameter) => await Agent.find(searchParameter).lean();
/**
* Load an agent based on the provided ID
*
@@ -61,8 +69,6 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
*/
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */
const availableTools = await getCachedTools({ userId: req.user.id, includeGlobal: true });
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
@@ -80,22 +86,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
const addedServers = new Set();
if (mcpServers.size > 0) {
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
continue;
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
addedServers.add(mcpServer);
tools.push(toolName);
}
}
for (const mcpServer of mcpServers) {
if (addedServers.has(mcpServer)) {
continue;
}
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
const serverTools = await getMCPServerTools(mcpServer);
if (!serverTools) {
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
addedServers.add(mcpServer);
continue;
}
tools.push(...Object.keys(serverTools));
addedServers.add(mcpServer);
}
}
@@ -835,6 +837,7 @@ const countPromotedAgents = async () => {
module.exports = {
getAgent,
getAgents,
loadAgent,
createAgent,
updateAgent,

View File

@@ -8,6 +8,7 @@ process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const mongoose = require('mongoose');
@@ -30,7 +31,7 @@ const {
generateActionMetadataHash,
} = require('./Agent');
const permissionService = require('~/server/services/PermissionService');
const { getCachedTools } = require('~/server/services/Config');
const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
const { AclEntry } = require('~/db/models');
/**
@@ -1929,6 +1930,16 @@ describe('models/Agent', () => {
another_tool: {},
});
// Mock getMCPServerTools to return tools for each server
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
return { tool1_mcp_server1: {} };
} else if (server === 'server2') {
return { tool2_mcp_server2: {} };
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -2113,6 +2124,14 @@ describe('models/Agent', () => {
getCachedTools.mockResolvedValue(availableTools);
// Mock getMCPServerTools to return all tools for server1
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
return availableTools; // All 100 tools belong to server1
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -2654,6 +2673,17 @@ describe('models/Agent', () => {
tool_mcp_server2: {}, // Different server
});
// Mock getMCPServerTools to return only tools matching the server
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
// Only return tool that correctly matches server1 format
return { tool_mcp_server1: {} };
} else if (server === 'server2') {
return { tool_mcp_server2: {} };
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {

View File

@@ -239,10 +239,46 @@ const updateTagsForConversation = async (user, conversationId, tags) => {
}
};
/**
* Increments tag counts for existing tags only.
* @param {string} user - The user ID.
* @param {string[]} tags - Array of tag names to increment
* @returns {Promise<void>}
*/
const bulkIncrementTagCounts = async (user, tags) => {
if (!tags || tags.length === 0) {
return;
}
try {
const uniqueTags = [...new Set(tags.filter(Boolean))];
if (uniqueTags.length === 0) {
return;
}
const bulkOps = uniqueTags.map((tag) => ({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
},
}));
const result = await ConversationTag.bulkWrite(bulkOps);
if (result && result.modifiedCount > 0) {
logger.debug(
`user: ${user} | Incremented tag counts - modified ${result.modifiedCount} tags`,
);
}
} catch (error) {
logger.error('[bulkIncrementTagCounts] Error incrementing tag counts', error);
}
};
module.exports = {
getConversationTags,
createConversationTag,
updateConversationTag,
deleteConversationTag,
bulkIncrementTagCounts,
updateTagsForConversation,
};

View File

@@ -42,7 +42,7 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => {
$or: [],
};
if (toolResourceSet.has(EToolResources.ocr)) {
if (toolResourceSet.has(EToolResources.context)) {
filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
}
if (toolResourceSet.has(EToolResources.file_search)) {

View File

@@ -211,7 +211,67 @@ describe('File Access Control', () => {
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should deny access when user only has VIEW permission', async () => {
it('should deny access when user only has VIEW permission and needs access for deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with files
const agent = await createAgent({
id: agentId,
name: 'View-Only Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: fileIds,
},
},
});
// Grant only VIEW permission to user on the agent
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
// Check access for files
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds,
agentId,
isDelete: true,
});
// Should have no access to any files when only VIEW permission
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should grant access when user has VIEW permission', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
@@ -265,9 +325,8 @@ describe('File Access Control', () => {
agentId,
});
// Should have no access to any files when only VIEW permission
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(true);
});
});

View File

@@ -269,7 +269,7 @@ async function getListPromptGroupsByAccess({
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
// Add cursor condition
if (after) {
if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;

View File

@@ -189,11 +189,15 @@ async function createAutoRefillTransaction(txData) {
* @param {txData} _txData - Transaction data.
*/
async function createTransaction(_txData) {
const { balance, ...txData } = _txData;
const { balance, transactions, ...txData } = _txData;
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
return;
}
if (transactions?.enabled === false) {
return;
}
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
calculateTokenValue(transaction);
@@ -222,7 +226,11 @@ async function createTransaction(_txData) {
* @param {txData} _txData - Transaction data.
*/
async function createStructuredTransaction(_txData) {
const { balance, ...txData } = _txData;
const { balance, transactions, ...txData } = _txData;
if (transactions?.enabled === false) {
return;
}
const transaction = new Transaction({
...txData,
endpointTokenConfig: txData.endpointTokenConfig,

View File

@@ -1,10 +1,9 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { getMultiplier, getCacheMultiplier } = require('./tx');
const { createTransaction } = require('./Transaction');
const { Balance } = require('~/db/models');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
const { Balance, Transaction } = require('~/db/models');
let mongoServer;
beforeAll(async () => {
@@ -380,3 +379,188 @@ describe('NaN Handling Tests', () => {
expect(balance.tokenCredits).toBe(initialBalance);
});
});
describe('Transactions Config Tests', () => {
test('createTransaction should not save when transactions.enabled is false', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
rawAmount: -100,
tokenType: 'prompt',
transactions: { enabled: false },
};
// Act
const result = await createTransaction(txData);
// Assert: No transaction should be created
expect(result).toBeUndefined();
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('createTransaction should save when transactions.enabled is true', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
rawAmount: -100,
tokenType: 'prompt',
transactions: { enabled: true },
balance: { enabled: true },
};
// Act
const result = await createTransaction(txData);
// Assert: Transaction should be created
expect(result).toBeDefined();
expect(result.balance).toBeLessThan(initialBalance);
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(1);
expect(transactions[0].rawAmount).toBe(-100);
});
test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
rawAmount: -100,
tokenType: 'prompt',
balance: { enabled: true },
// No transactions config provided
};
// Act
const result = await createTransaction(txData);
// Assert: Transaction should be created (backward compatibility)
expect(result).toBeDefined();
expect(result.balance).toBeLessThan(initialBalance);
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(1);
});
test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
rawAmount: -100,
tokenType: 'prompt',
transactions: { enabled: true },
balance: { enabled: false },
};
// Act
const result = await createTransaction(txData);
// Assert: Transaction should be created but balance unchanged
expect(result).toBeUndefined();
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(1);
expect(transactions[0].rawAmount).toBe(-100);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('createStructuredTransaction should not save when transactions.enabled is false', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'message',
tokenType: 'prompt',
inputTokens: -10,
writeTokens: -100,
readTokens: -5,
transactions: { enabled: false },
};
// Act
const result = await createStructuredTransaction(txData);
// Assert: No transaction should be created
expect(result).toBeUndefined();
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'message',
tokenType: 'prompt',
inputTokens: -10,
writeTokens: -100,
readTokens: -5,
transactions: { enabled: true },
balance: { enabled: false },
};
// Act
const result = await createStructuredTransaction(txData);
// Assert: Transaction should be created but balance unchanged
expect(result).toBeUndefined();
const transactions = await Transaction.find({ user: userId });
expect(transactions).toHaveLength(1);
expect(transactions[0].inputTokens).toBe(-10);
expect(transactions[0].writeTokens).toBe(-100);
expect(transactions[0].readTokens).toBe(-5);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
});

View File

@@ -1,4 +1,4 @@
const { matchModelName } = require('../utils/tokens');
const { matchModelName } = require('@librechat/api');
const defaultRate = 6;
/**

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -49,14 +49,14 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.76",
"@librechat/agents": "^2.4.80",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.17.1",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"axios": "^1.12.1",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",

View File

@@ -1,8 +1,8 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const {
requestPasswordReset,
setOpenIDAuthTokens,
@@ -11,8 +11,9 @@ const {
registerUser,
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOAuthReconnectionManager } = require('~/config');
const { getOpenIdConfig } = require('~/strategies');
const registrationController = async (req, res) => {
try {
@@ -71,11 +72,17 @@ const refreshController = async (req, res) => {
const openIdConfig = getOpenIdConfig();
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });
if (!user) {
const { user, error } = await findOpenIDUser({
findUser,
email: claims.email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'refreshController',
});
if (error || !user) {
return res.status(401).redirect('/login');
}
const token = setOpenIDAuthTokens(tokenset, res);
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
return res.status(200).send({ token, user });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);
@@ -96,14 +103,25 @@ const refreshController = async (req, res) => {
return res.status(200).send({ token, user });
}
// Find the session with the hashed refresh token
const session = await findSession({
userId: userId,
refreshToken: refreshToken,
});
/** Session with the hashed refresh token */
const session = await findSession(
{
userId: userId,
refreshToken: refreshToken,
},
{ lean: false },
);
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort)
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('Error reconnecting OAuth MCP servers:', err);
});
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
@@ -114,7 +132,7 @@ const refreshController = async (req, res) => {
res.status(401).send('Refresh token expired or not found for this user');
}
} catch (err) {
logger.error(`[refreshController] Refresh token: ${refreshToken}`, err);
logger.error(`[refreshController] Invalid refresh token:`, err);
res.status(403).send('Invalid refresh token');
}
};

View File

@@ -1,16 +1,9 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
getToolkitKey,
checkPluginAuth,
filterUniquePlugins,
convertMCPToolToPlugin,
convertMCPToolsToPlugins,
} = require('@librechat/api');
const { getCachedTools, setCachedTools, mergeUserTools } = require('~/server/services/Config');
const { CacheKeys } = require('librechat-data-provider');
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getAppConfig } = require('~/server/services/Config');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
@@ -72,54 +65,27 @@ const getAvailableTools = async (req, res) => {
}
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId });
const mcpManager = getMCPManager();
const userPlugins =
cachedUserTools != null
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager })
: undefined;
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
res.status(200).json(dedupedTools);
// Return early if we have cached tools
if (cachedToolsArray != null) {
res.status(200).json(cachedToolsArray);
return;
}
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
let toolDefinitions = await getCachedTools({ includeGlobal: true });
let prelimCachedTools;
let toolDefinitions = await getCachedTools();
if (toolDefinitions == null && appConfig?.availableTools != null) {
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
await setCachedTools(appConfig.availableTools);
toolDefinitions = appConfig.availableTools;
}
/** @type {import('@librechat/api').LCManifestTool[]} */
let pluginManifest = availableTools;
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
if (appConfig?.mcpConfig != null) {
try {
const mcpTools = await mcpManager.getAllToolFunctions(userId);
prelimCachedTools = prelimCachedTools ?? {};
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
const plugin = convertMCPToolToPlugin({
toolKey,
toolData,
mcpManager,
});
if (plugin) {
pluginManifest.push(plugin);
}
prelimCachedTools[toolKey] = toolData;
}
await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools });
} catch (error) {
logger.error(
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
error,
);
}
} else if (prelimCachedTools != null) {
await setCachedTools(prelimCachedTools, { isGlobal: true });
}
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
const uniquePlugins = filterUniquePlugins(pluginManifest);
const authenticatedPlugins = uniquePlugins.map((plugin) => {
@@ -130,13 +96,13 @@ const getAvailableTools = async (req, res) => {
}
});
/** Filter plugins based on availability and add MCP-specific auth config */
/** Filter plugins based on availability */
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some(
Object.keys(toolDefinitions ?? {}).some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
@@ -144,39 +110,13 @@ const getAvailableTools = async (req, res) => {
continue;
}
const toolToAdd = { ...plugin };
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = appConfig?.mcpConfig?.[serverName];
if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
toolToAdd.authConfig = [];
toolToAdd.authenticated = true;
} else {
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(
([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}),
);
toolToAdd.authenticated = false;
}
}
}
toolsOutput.push(toolToAdd);
toolsOutput.push(plugin);
}
const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools);
const dedupedTools = filterUniquePlugins([...(userPlugins ?? []), ...finalTools]);
res.status(200).json(dedupedTools);
res.status(200).json(finalTools);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });

View File

@@ -1,4 +1,3 @@
const { Constants } = require('librechat-data-provider');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@@ -17,18 +16,10 @@ jest.mock('~/server/services/Config', () => ({
includedTools: [],
}),
setCachedTools: jest.fn(),
mergeUserTools: jest.fn(),
}));
// loadAndFormatTools mock removed - no longer used in PluginController
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
getAllToolFunctions: jest.fn().mockResolvedValue({}),
getRawConfig: jest.fn().mockReturnValue({}),
})),
getFlowStateManager: jest.fn(),
}));
// getMCPManager mock removed - no longer used in PluginController
jest.mock('~/app/clients/tools', () => ({
availableTools: [],
@@ -159,43 +150,6 @@ describe('PluginController', () => {
});
describe('getAvailableTools', () => {
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
parameters: { type: 'object', properties: {} },
},
},
};
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock second call to return tool definitions (includeGlobal: true)
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toBeDefined();
expect(Array.isArray(responseData)).toBe(true);
expect(responseData.length).toBeGreaterThan(0);
const convertedTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
);
expect(convertedTool).toBeDefined();
// The real convertMCPToolsToPlugins extracts the name from the delimiter
expect(convertedTool.name).toBe('tool1');
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserTools = {
'user-tool': {
@@ -220,9 +174,6 @@ describe('PluginController', () => {
paths: { structuredTools: '/mock/path' },
};
// Mock second call to return tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
@@ -245,14 +196,7 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
// First call returns null for user tools
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Second call (with includeGlobal: true) returns the tool definitions
// getCachedTools returns the tool definitions
getCachedTools.mockResolvedValueOnce({
tool1: {
type: 'function',
@@ -263,6 +207,10 @@ describe('PluginController', () => {
},
},
});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
@@ -293,14 +241,7 @@ describe('PluginController', () => {
});
mockCache.get.mockResolvedValue(null);
// First call returns null for user tools
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Second call (with includeGlobal: true) returns the tool definitions
// getCachedTools returns the tool definitions
getCachedTools.mockResolvedValueOnce({
toolkit1_function: {
type: 'function',
@@ -311,6 +252,10 @@ describe('PluginController', () => {
},
},
});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
@@ -322,126 +267,7 @@ describe('PluginController', () => {
});
});
describe('plugin.icon behavior', () => {
const callGetAvailableToolsWithMCPServer = async (serverConfig) => {
mockCache.get.mockResolvedValue(null);
const functionTools = {
[`test-tool${Constants.mcp_delimiter}test-server`]: {
type: 'function',
function: {
name: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
parameters: { type: 'object', properties: {} },
},
},
};
// Mock the MCP manager to return tools and server config
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
getRawConfig: jest.fn().mockReturnValue(serverConfig),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
// First call returns empty user tools
getCachedTools.mockResolvedValueOnce({});
// Mock getAppConfig to return the mcpConfig
mockReq.config = {
mcpConfig: {
'test-server': serverConfig,
},
};
// Second call (with includeGlobal: true) returns the tool definitions
getCachedTools.mockResolvedValueOnce(functionTools);
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
return responseData.find(
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
);
};
it('should set plugin.icon when iconPath is defined', async () => {
const serverConfig = {
iconPath: '/path/to/icon.png',
};
const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
expect(testTool.icon).toBe('/path/to/icon.png');
});
it('should set plugin.icon to undefined when iconPath is not defined', async () => {
const serverConfig = {};
const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
expect(testTool.icon).toBeUndefined();
});
});
describe('helper function integration', () => {
it('should properly handle MCP tools with custom user variables', async () => {
const appConfig = {
mcpConfig: {
'test-server': {
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
},
},
};
// Mock MCP tools returned by getAllToolFunctions
const mcpToolFunctions = {
[`tool1${Constants.mcp_delimiter}test-server`]: {
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
parameters: {},
},
},
};
// Mock the MCP manager to return tools
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null);
mockReq.config = appConfig;
// First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({});
// Second call (with includeGlobal: true) returns tool definitions including our MCP tool
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
// Find the MCP tool in the response
const mcpTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
);
// The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
expect(mcpTool).toBeDefined();
expect(mcpTool.authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
]);
expect(mcpTool.authenticated).toBe(false);
});
it('should handle error cases gracefully', async () => {
mockCache.get.mockRejectedValue(new Error('Cache error'));
@@ -463,23 +289,13 @@ describe('PluginController', () => {
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
// First call returns null for user tools
getCachedTools.mockResolvedValueOnce(null);
// getCachedTools returns empty object instead of null
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock MCP manager to return no tools
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue({}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
// Second call (with includeGlobal: true) returns empty object instead of null
getCachedTools.mockResolvedValueOnce({});
await getAvailableTools(mockReq, mockRes);
// Should handle null values gracefully
@@ -494,9 +310,9 @@ describe('PluginController', () => {
paths: { structuredTools: '/mock/path' },
};
// Mock getCachedTools to return undefined for both calls
// Mock getCachedTools to return undefined
getCachedTools.mockReset();
getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
getCachedTools.mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
@@ -505,42 +321,6 @@ describe('PluginController', () => {
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
// Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
const userTools = {
[`user-tool${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: {
name: `user-tool${Constants.mcp_delimiter}server1`,
description: 'User tool',
parameters: {},
},
},
};
mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValueOnce(userTools);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// The controller expects a second call to getCachedTools
getCachedTools.mockResolvedValueOnce({
'cached-tool': { type: 'function', function: { name: 'cached-tool' } },
[`user-tool${Constants.mcp_delimiter}server1`]:
userTools[`user-tool${Constants.mcp_delimiter}server1`],
});
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
// Should have both cached and user tools
expect(responseData.length).toBeGreaterThanOrEqual(2);
});
it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null);
// Reset getCachedTools to ensure clean state
@@ -551,76 +331,12 @@ describe('PluginController', () => {
// Ensure no plugins are available
require('~/app/clients/tools').availableTools.length = 0;
// Reset MCP manager to default state
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue({}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
await getAvailableTools(mockReq, mockRes);
// With empty tool definitions, no tools should be in the final output
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle MCP tools without customUserVars', async () => {
const appConfig = {
mcpConfig: {
'test-server': {
// No customUserVars defined
},
},
};
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}test-server`]: {
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
parameters: { type: 'object', properties: {} },
},
},
};
// Mock the MCP manager to return the tools
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(mockUserTools),
getRawConfig: jest.fn().mockReturnValue({
// No customUserVars defined
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null);
mockReq.config = appConfig;
// First call returns empty user tools
getCachedTools.mockResolvedValueOnce({});
// Second call (with includeGlobal: true) returns the tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
// Ensure no plugins in availableTools for clean test
require('~/app/clients/tools').availableTools.length = 0;
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
expect(responseData.length).toBeGreaterThan(0);
const mcpTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
);
expect(mcpTool).toBeDefined();
expect(mcpTool.authenticated).toBe(true);
// The actual implementation sets authConfig to empty array when no customUserVars
expect(mcpTool.authConfig).toEqual([]);
});
it('should handle undefined filteredTools and includedTools', async () => {
mockReq.config = {};
mockCache.get.mockResolvedValue(null);
@@ -649,20 +365,129 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(mockToolkit);
mockCache.get.mockResolvedValue(null);
// First call returns empty object
// getCachedTools returns empty object to avoid null reference error
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Second call (with includeGlobal: true) returns empty object to avoid null reference error
getCachedTools.mockResolvedValueOnce({});
await getAvailableTools(mockReq, mockRes);
// Should handle null toolDefinitions gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => {
// This test reproduces the bug where toolDefinitions is undefined
// and accessing toolDefinitions[plugin.pluginKey] causes a TypeError
const mockPlugin = {
name: 'Traversaal Search',
pluginKey: 'traversaal_search',
description: 'Search plugin',
};
// Add the plugin to availableTools
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// CRITICAL: getCachedTools returns undefined
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
getCachedTools.mockResolvedValueOnce(undefined);
// This should not throw an error with the optional chaining fix
await getAvailableTools(mockReq, mockRes);
// Should handle undefined toolDefinitions gracefully and return empty array
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should re-initialize tools from appConfig when cache returns null', async () => {
// Setup: Initial state with tools in appConfig
const mockAppTools = {
tool1: {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
tool2: {
type: 'function',
function: {
name: 'tool2',
description: 'Tool 2',
parameters: {},
},
},
};
// Add matching plugins to availableTools
require('~/app/clients/tools').availableTools.push(
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
);
// Simulate cache cleared state (returns null)
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
mockReq.config = {
filteredTools: [],
includedTools: [],
availableTools: mockAppTools,
};
// Mock setCachedTools to verify it's called to re-initialize
const { setCachedTools } = require('~/server/services/Config');
await getAvailableTools(mockReq, mockRes);
// Should have re-initialized the cache with tools from appConfig
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
// Should still return tools successfully
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
});
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
// Setup: appConfig without availableTools
getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: [],
// No availableTools property
});
// Clear availableTools array
require('~/app/clients/tools').availableTools.length = 0;
// Cache returns null (cleared state)
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
mockReq.config = {
filteredTools: [],
includedTools: [],
// No availableTools
};
await getAvailableTools(mockReq, mockRes);
// Should handle gracefully without crashing
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
});
});

View File

@@ -1,26 +1,34 @@
const { logger } = require('@librechat/data-schemas');
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider');
const {
webSearchKeys,
MCPOAuthHandler,
MCPTokenStorage,
normalizeHttpError,
extractWebSearchEnvVars,
} = require('@librechat/api');
const {
getFiles,
findToken,
updateUser,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
deleteAllSharedLinks,
deleteAllUserSessions,
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { Tools, Constants, FileSources } = require('librechat-data-provider');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User } = require('~/db/models');
const { Transaction, Balance, User, Token } = require('~/db/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -162,6 +170,15 @@ const updateUserPluginsController = async (req, res) => {
);
({ status, message } = normalizeHttpError(authService));
}
try {
// if the MCP server uses OAuth, perform a full cleanup and token revocation
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
} catch (error) {
logger.error(
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
error,
);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
@@ -187,7 +204,7 @@ const updateUserPluginsController = async (req, res) => {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info(
`[updateUserPluginsController] Disconnecting MCP server ${serverName} for user ${user.id} after plugin auth update for ${pluginKey}.`,
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
}
@@ -269,6 +286,94 @@ const resendVerificationController = async (req, res) => {
}
};
/**
* OAuth MCP specific uninstall logic
*/
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
// this is not an MCP server, so nothing to do here
return;
}
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
const mcpManager = getMCPManager(userId);
const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
if (!mcpManager.getOAuthServers().has(serverName)) {
// this server does not use OAuth, so nothing to do here as well
return;
}
// 1. get client info used for revocation (client id, secret)
const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
userId,
serverName,
findToken,
});
if (clientTokenData == null) {
return;
}
const { clientInfo, clientMetadata } = clientTokenData;
// 2. get decrypted tokens before deletion
const tokens = await MCPTokenStorage.getTokens({
userId,
serverName,
findToken,
});
// 3. revoke OAuth tokens at the provider
const revocationEndpoint =
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
const revocationEndpointAuthMethodsSupported =
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
if (tokens?.access_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
});
} catch (error) {
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
}
}
if (tokens?.refresh_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
});
} catch (error) {
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
}
}
// 4. delete tokens from the DB after revocation attempts
await MCPTokenStorage.deleteUserTokens({
userId,
serverName,
deleteToken: async (filter) => {
await Token.deleteOne(filter);
},
});
// 5. clear the flow state for the OAuth tokens
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
await flowManager.deleteFlow(flowId, 'mcp_oauth');
};
module.exports = {
getUserController,
getTermsStatusController,

View File

@@ -0,0 +1,342 @@
const { Tools } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-id'),
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
Providers: { GOOGLE: 'google' },
GraphEvents: {},
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked logger
logger = require('@librechat/data-schemas').logger;
// Now require the module after all mocks are set up
const callbacks = require('../callbacks');
createToolEndCallback = callbacks.createToolEndCallback;
req = {
user: { id: 'user123' },
};
res = {
headersSent: false,
write: jest.fn(),
};
artifactPromises = [];
});
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
const results = await Promise.all(artifactPromises);
// When headers are not sent, it returns attachment without writing
expect(res.write).not.toHaveBeenCalled();
const attachment = results[0];
expect(attachment).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
});
});
it('should write to response when headers are already sent', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'carousel', items: [] },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(res.write).toHaveBeenCalled();
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'carousel', items: [] },
},
});
});
it('should handle errors when processing ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
// Mock res.write to throw an error
res.headersSent = true;
res.write.mockImplementation(() => {
throw new Error('Write failed');
});
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'test' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(logger.error).toHaveBeenCalledWith(
'Error processing artifact content:',
expect.any(Error),
);
expect(results[0]).toBeNull();
});
it('should handle multiple artifacts including ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'chart', data: [] },
},
},
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
// Both ui_resources and web_search should be processed
expect(artifactPromises).toHaveLength(2);
expect(results).toHaveLength(2);
// Check ui_resources attachment
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
expect(uiResourceAttachment).toBeTruthy();
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
0: { type: 'chart', data: [] },
});
// Check web_search attachment
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
expect(webSearchAttachment).toBeTruthy();
expect(webSearchAttachment[Tools.web_search]).toEqual({
results: ['result1', 'result2'],
});
});
it('should not process artifacts when output has no artifacts', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
content: 'Some regular content',
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty ui_resources data object', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {},
});
});
it('should handle ui_resources with complex nested data', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const complexData = {
0: {
type: 'form',
fields: [
{ name: 'field1', type: 'text', required: true },
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
],
nested: {
deep: {
value: 123,
array: [1, 2, 3],
},
},
},
};
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: complexData,
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
});

View File

@@ -158,7 +158,7 @@ describe('duplicateAgent', () => {
});
});
it('should handle tool_resources.ocr correctly', async () => {
it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
@@ -178,7 +178,7 @@ describe('duplicateAgent', () => {
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
ocr: { enabled: true, config: 'test' },
context: { enabled: true, config: 'test' },
},
}),
);

View File

@@ -265,6 +265,30 @@ function createToolEndCallback({ req, res, artifactPromises }) {
);
}
// TODO: a lot of duplicated code in createToolEndCallback
// we should refactor this to use a helper function in a follow-up PR
if (output.artifact[Tools.ui_resources]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.ui_resources,
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
};
if (!res.headersSent) {
return attachment;
}
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact[Tools.web_search]) {
artifactPromises.push(
(async () => {

View File

@@ -7,9 +7,12 @@ const {
createRun,
Tokenizer,
checkAccess,
logAxiosError,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
formatContentStrings,
getTransactionsConfig,
createMemoryProcessor,
} = require('@librechat/api');
const {
@@ -86,11 +89,10 @@ function createTokenCounter(encoding) {
}
function logToolError(graph, error, toolId) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
logAxiosError({
error,
toolId,
);
message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`,
});
}
class AgentClient extends BaseClient {
@@ -622,11 +624,13 @@ class AgentClient extends BaseClient {
* @param {string} [params.model]
* @param {string} [params.context='message']
* @param {AppConfig['balance']} [params.balance]
* @param {AppConfig['transactions']} [params.transactions]
* @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage]
*/
async recordCollectedUsage({
model,
balance,
transactions,
context = 'message',
collectedUsage = this.collectedUsage,
}) {
@@ -652,6 +656,7 @@ class AgentClient extends BaseClient {
const txMetadata = {
context,
balance,
transactions,
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
@@ -867,11 +872,10 @@ class AgentClient extends BaseClient {
if (agent.useLegacyContent === true) {
messages = formatContentStrings(messages);
}
if (
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
'prompt-caching',
)
) {
const defaultHeaders =
agent.model_parameters?.clientOptions?.defaultHeaders ??
agent.model_parameters?.configuration?.defaultHeaders;
if (defaultHeaders?.['anthropic-beta']?.includes('prompt-caching')) {
messages = addCacheControl(messages);
}
@@ -879,6 +883,16 @@ class AgentClient extends BaseClient {
memoryPromise = this.runMemory(messages);
}
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
* non-custom endpoints, needs consideration of varying provider header configs.
*/
if (agent.model_parameters?.configuration?.defaultHeaders != null) {
agent.model_parameters.configuration.defaultHeaders = resolveHeaders({
headers: agent.model_parameters.configuration.defaultHeaders,
body: config.configurable.requestBody,
});
}
run = await createRun({
agent,
req: this.options.req,
@@ -1040,7 +1054,12 @@ class AgentClient extends BaseClient {
}
const balanceConfig = getBalanceConfig(appConfig);
await this.recordCollectedUsage({ context: 'message', balance: balanceConfig });
const transactionsConfig = getTransactionsConfig(appConfig);
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
@@ -1181,6 +1200,20 @@ class AgentClient extends BaseClient {
clientOptions.json = true;
}
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
* non-custom endpoints, needs consideration of varying provider header configs.
*/
if (clientOptions?.configuration?.defaultHeaders != null) {
clientOptions.configuration.defaultHeaders = resolveHeaders({
headers: clientOptions.configuration.defaultHeaders,
body: {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
});
}
try {
const titleResult = await this.run.generateTitle({
provider,
@@ -1220,11 +1253,13 @@ class AgentClient extends BaseClient {
});
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
await this.recordCollectedUsage({
collectedUsage,
context: 'title',
model: clientOptions.model,
balance: balanceConfig,
transactions: transactionsConfig,
}).catch((err) => {
logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',

View File

@@ -237,6 +237,9 @@ describe('AgentClient - titleConvo', () => {
balance: {
enabled: false,
},
transactions: {
enabled: true,
},
});
});

View File

@@ -2,9 +2,15 @@ const { z } = require('zod');
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
const {
agentCreateSchema,
agentUpdateSchema,
mergeAgentOcrConversion,
convertOcrToContextInPlace,
} = require('@librechat/api');
const {
Tools,
Constants,
SystemRoles,
FileSources,
ResourceType,
@@ -65,13 +71,13 @@ const createAgentHandler = async (req, res) => {
agentData.author = userId;
agentData.tools = [];
const availableTools = await getCachedTools({ includeGlobal: true });
const availableTools = await getCachedTools();
for (const tool of tools) {
if (availableTools[tool]) {
agentData.tools.push(tool);
}
if (systemTools[tool]) {
} else if (systemTools[tool]) {
agentData.tools.push(tool);
} else if (tool.includes(Constants.mcp_delimiter)) {
agentData.tools.push(tool);
}
}
@@ -197,19 +203,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @param {AgentUpdateParams} req.body - The Agent update parameters.
* @returns {Agent} 200 - success response - application/json
* @returns {Promise<Agent>} 200 - success response - application/json
*/
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body);
const { _id, ...updateData } = removeNullishValues(validatedData);
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
const existingAgent = await getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
// Convert legacy OCR tool resource to context format in existing agent
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
if (ocrConversion.tool_resources) {
updateData.tool_resources = ocrConversion.tool_resources;
}
if (ocrConversion.tools) {
updateData.tools = ocrConversion.tools;
}
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
@@ -254,7 +273,7 @@ const updateAgentHandler = async (req, res) => {
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - success response - application/json
* @returns {Promise<Agent>} 201 - success response - application/json
*/
const duplicateAgentHandler = async (req, res) => {
const { id } = req.params;
@@ -287,9 +306,19 @@ const duplicateAgentHandler = async (req, res) => {
hour12: false,
})})`;
if (_tool_resources?.[EToolResources.context]) {
cloneData.tool_resources = {
[EToolResources.context]: _tool_resources[EToolResources.context],
};
}
if (_tool_resources?.[EToolResources.ocr]) {
cloneData.tool_resources = {
[EToolResources.ocr]: _tool_resources[EToolResources.ocr],
/** Legacy conversion from `ocr` to `context` */
[EToolResources.context]: {
...(_tool_resources[EToolResources.context] ?? {}),
..._tool_resources[EToolResources.ocr],
},
};
}
@@ -381,7 +410,7 @@ const duplicateAgentHandler = async (req, res) => {
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json
* @returns {Promise<Agent>} 200 - success response - application/json
*/
const deleteAgentHandler = async (req, res) => {
try {
@@ -483,7 +512,7 @@ const getListAgentsHandler = async (req, res) => {
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {object} req.body - Request body
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
* @returns {Object} 200 - success response - application/json
* @returns {Promise<void>} 200 - success response - application/json
*/
const uploadAgentAvatarHandler = async (req, res) => {
try {

View File

@@ -512,6 +512,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
mockReq.params.id = existingAgentId;
mockReq.body = {
tool_resources: {
/** Legacy conversion from `ocr` to `context` */
ocr: {
file_ids: ['ocr1', 'ocr2'],
},
@@ -531,7 +532,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tool_resources).toBeDefined();
expect(updatedAgent.tool_resources.ocr).toBeDefined();
expect(updatedAgent.tool_resources.ocr).toBeUndefined();
expect(updatedAgent.tool_resources.context).toBeDefined();
expect(updatedAgent.tool_resources.execute_code).toBeDefined();
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
});

View File

@@ -1,7 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig } = require('@librechat/api');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
const {
Time,
Constants,
@@ -34,7 +34,6 @@ const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
/**

View File

@@ -1,7 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig } = require('@librechat/api');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
const {
Time,
Constants,
@@ -31,7 +31,6 @@ const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
/**

View File

@@ -31,7 +31,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const toolDefinitions = await getCachedTools();
assistantData.tools = tools
.map((tool) => {
@@ -136,7 +136,7 @@ const patchAssistant = async (req, res) => {
...updateData
} = req.body;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const toolDefinitions = await getCachedTools();
updateData.tools = (updateData.tools ?? [])
.map((tool) => {

View File

@@ -28,7 +28,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const toolDefinitions = await getCachedTools();
assistantData.tools = tools
.map((tool) => {
@@ -125,7 +125,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const toolDefinitions = await getCachedTools();
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {

View File

@@ -0,0 +1,126 @@
/**
* MCP Tools Controller
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
*/
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const {
cacheMCPServerTools,
getMCPServerTools,
getAppConfig,
} = require('~/server/services/Config');
const { getMCPManager } = require('~/config');
/**
* Get all MCP tools available to the user
*/
const getMCPTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getMCPTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
if (!appConfig?.mcpConfig) {
return res.status(200).json({ servers: {} });
}
const mcpManager = getMCPManager();
const configuredServers = Object.keys(appConfig.mcpConfig);
const mcpServers = {};
const cachePromises = configuredServers.map((serverName) =>
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
);
const cacheResults = await Promise.all(cachePromises);
const serverToolsMap = new Map();
for (const { serverName, tools } of cacheResults) {
if (tools) {
serverToolsMap.set(serverName, tools);
continue;
}
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;
}
serverToolsMap.set(serverName, serverTools);
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
}
// Process each configured server
for (const serverName of configuredServers) {
try {
const serverTools = serverToolsMap.get(serverName);
// Get server config once
const serverConfig = appConfig.mcpConfig[serverName];
const rawServerConfig = mcpManager.getRawConfig(serverName);
// Initialize server object with all server-level data
const server = {
name: serverName,
icon: rawServerConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
};
// Set authentication config once for the server
if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length > 0) {
server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
server.authenticated = false;
}
}
// Process tools efficiently - no need for convertMCPToolToPlugin
if (serverTools) {
for (const [toolKey, toolData] of Object.entries(serverTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const toolName = toolKey.split(Constants.mcp_delimiter)[0];
server.tools.push({
name: toolName,
pluginKey: toolKey,
description: toolData.function.description || '',
});
}
}
// Only add server if it has tools or is configured
if (server.tools.length > 0 || serverConfig) {
mcpServers[serverName] = server;
}
} catch (error) {
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
}
}
res.status(200).json({ servers: mcpServers });
} catch (error) {
logger.error('[getMCPTools]', error);
res.status(500).json({ message: error.message });
}
};
module.exports = {
getMCPTools,
};

View File

@@ -12,7 +12,8 @@ const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { isEnabled, ErrorController } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const validateImageRequest = require('./middleware/validateImageRequest');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
@@ -126,7 +127,7 @@ const startServer = async () => {
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', validateImageRequest, routes.staticRoute);
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
@@ -154,7 +155,7 @@ const startServer = async () => {
res.send(updatedIndexHtml);
});
app.listen(port, host, () => {
app.listen(port, host, async () => {
if (host === '0.0.0.0') {
logger.info(
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
@@ -163,7 +164,9 @@ const startServer = async () => {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
initializeMCPs().then(() => checkMigrations());
await initializeMCPs();
await initializeOAuthReconnectManager();
await checkMigrations();
});
};

View File

@@ -1,7 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { getAgent } = require('~/models/Agent');
const { getAgents } = require('~/models/Agent');
const { getFiles } = require('~/models/File');
/**
@@ -10,11 +10,12 @@ const { getFiles } = require('~/models/File');
*/
const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
try {
// Find agents that have this file in their tool_resources
const agentsWithFile = await getAgent({
/** Agents that have this file in their tool_resources */
const agentsWithFile = await getAgents({
$or: [
{ 'tool_resources.file_search.file_ids': fileId },
{ 'tool_resources.execute_code.file_ids': fileId },
{ 'tool_resources.file_search.file_ids': fileId },
{ 'tool_resources.context.file_ids': fileId },
{ 'tool_resources.ocr.file_ids': fileId },
],
});
@@ -24,7 +25,7 @@ const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
}
// Check if user has access to any of these agents
for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) {
for (const agent of agentsWithFile) {
// Check if user is the agent author
if (agent.author && agent.author.toString() === userId) {
logger.debug(`[fileAccess] User is author of agent ${agent.id}`);
@@ -83,7 +84,6 @@ const fileAccess = async (req, res, next) => {
});
}
// Get the file
const [file] = await getFiles({ file_id: fileId });
if (!file) {
return res.status(404).json({
@@ -92,20 +92,18 @@ const fileAccess = async (req, res, next) => {
});
}
// Check if user owns the file
if (file.user && file.user.toString() === userId) {
req.fileAccess = { file };
return next();
}
// Check agent-based access (file inherits agent permissions)
/** Agent-based access (file inherits agent permissions) */
const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId });
if (hasAgentAccess) {
req.fileAccess = { file };
return next();
}
// No access
logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`);
return res.status(403).json({
error: 'Forbidden',

View File

@@ -0,0 +1,483 @@
const mongoose = require('mongoose');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { fileAccess } = require('./fileAccess');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
const { createFile } = require('~/models/File');
describe('fileAccess middleware', () => {
let mongoServer;
let req, res, next;
let testUser, otherUser, thirdUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
// Create test role
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create test users
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
thirdUser = await User.create({
email: 'third@example.com',
name: 'Third User',
username: 'thirduser',
role: 'test-role',
});
// Setup request/response objects
req = {
user: { id: testUser._id.toString(), role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('basic file access', () => {
test('should allow access when user owns the file', async () => {
// Create a file owned by testUser
await createFile({
user: testUser._id.toString(),
file_id: 'file_owned_by_user',
filepath: '/test/file.txt',
filename: 'file.txt',
type: 'text/plain',
size: 100,
});
req.params.file_id = 'file_owned_by_user';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
expect(req.fileAccess.file).toBeDefined();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user does not own the file and no agent access', async () => {
// Create a file owned by otherUser
await createFile({
user: otherUser._id.toString(),
file_id: 'file_owned_by_other',
filepath: '/test/file.txt',
filename: 'file.txt',
type: 'text/plain',
size: 100,
});
req.params.file_id = 'file_owned_by_other';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this file',
});
});
test('should return 404 when file does not exist', async () => {
req.params.file_id = 'non_existent_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'File not found',
});
});
test('should return 400 when file_id is missing', async () => {
// Don't set file_id in params
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'file_id is required',
});
});
test('should return 401 when user is not authenticated', async () => {
req.user = null;
req.params.file_id = 'some_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
});
describe('agent-based file access', () => {
beforeEach(async () => {
// Create a file owned by otherUser (not testUser)
await createFile({
user: otherUser._id.toString(),
file_id: 'shared_file_via_agent',
filepath: '/test/shared.txt',
filename: 'shared.txt',
type: 'text/plain',
size: 100,
});
});
test('should allow access when user is author of agent with file', async () => {
// Create agent owned by testUser with the file
await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {
file_search: {
file_ids: ['shared_file_via_agent'],
},
},
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
expect(req.fileAccess.file).toBeDefined();
});
test('should allow access when user has VIEW permission on agent with file', async () => {
// Create agent owned by otherUser
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
execute_code: {
file_ids: ['shared_file_via_agent'],
},
},
});
// Grant VIEW permission to testUser
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should check file in ocr tool_resources', async () => {
await createAgent({
id: `agent_ocr_${Date.now()}`,
name: 'OCR Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {
ocr: {
file_ids: ['shared_file_via_agent'],
},
},
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should deny access when user has no permission on agent with file', async () => {
// Create agent owned by otherUser without granting permission to testUser
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['shared_file_via_agent'],
},
},
});
// Create ACL entry for otherUser only (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('multiple agents with same file', () => {
/**
* This test suite verifies that when multiple agents have the same file,
* all agents are checked for permissions, not just the first one found.
* This ensures users can access files through any agent they have permission for.
*/
test('should check ALL agents with file, not just first one', async () => {
// Create a file owned by someone else
await createFile({
user: otherUser._id.toString(),
file_id: 'multi_agent_file',
filepath: '/test/multi.txt',
filename: 'multi.txt',
type: 'text/plain',
size: 100,
});
// Create first agent (owned by otherUser, no access for testUser)
const agent1 = await createAgent({
id: 'agent_no_access',
name: 'No Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_agent_file'],
},
},
});
// Create ACL for agent1 - only otherUser has access
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent1._id,
permBits: 15,
grantedBy: otherUser._id,
});
// Create second agent (owned by thirdUser, but testUser has VIEW access)
const agent2 = await createAgent({
id: 'agent_with_access',
name: 'Accessible Agent',
provider: 'openai',
model: 'gpt-4',
author: thirdUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_agent_file'],
},
},
});
// Grant testUser VIEW access to agent2
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
permBits: 1, // VIEW permission
grantedBy: thirdUser._id,
});
req.params.file_id = 'multi_agent_file';
await fileAccess(req, res, next);
/**
* Should succeed because testUser has access to agent2,
* even though they don't have access to agent1.
* The fix ensures all agents are checked, not just the first one.
*/
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
expect(res.status).not.toHaveBeenCalled();
});
test('should find file in any agent tool_resources type', async () => {
// Create a file
await createFile({
user: otherUser._id.toString(),
file_id: 'multi_tool_file',
filepath: '/test/tool.txt',
filename: 'tool.txt',
type: 'text/plain',
size: 100,
});
// Agent 1: file in file_search (no access for testUser)
await createAgent({
id: 'agent_file_search',
name: 'File Search Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_tool_file'],
},
},
});
// Agent 2: same file in execute_code (testUser has access)
await createAgent({
id: 'agent_execute_code',
name: 'Execute Code Agent',
provider: 'openai',
model: 'gpt-4',
author: thirdUser._id,
tool_resources: {
execute_code: {
file_ids: ['multi_tool_file'],
},
},
});
// Agent 3: same file in ocr (testUser also has access)
await createAgent({
id: 'agent_ocr',
name: 'OCR Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id, // testUser owns this one
tool_resources: {
ocr: {
file_ids: ['multi_tool_file'],
},
},
});
req.params.file_id = 'multi_tool_file';
await fileAccess(req, res, next);
/**
* Should succeed because testUser owns agent3,
* even if other agents with the file are found first.
*/
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
});
describe('edge cases', () => {
test('should handle agent with empty tool_resources', async () => {
await createFile({
user: otherUser._id.toString(),
file_id: 'orphan_file',
filepath: '/test/orphan.txt',
filename: 'orphan.txt',
type: 'text/plain',
size: 100,
});
// Create agent with no files in tool_resources
await createAgent({
id: `agent_empty_${Date.now()}`,
name: 'Empty Resources Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {},
});
req.params.file_id = 'orphan_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('should handle agent with null tool_resources', async () => {
await createFile({
user: otherUser._id.toString(),
file_id: 'another_orphan_file',
filepath: '/test/orphan2.txt',
filename: 'orphan2.txt',
type: 'text/plain',
size: 100,
});
// Create agent with null tool_resources
await createAgent({
id: `agent_null_${Date.now()}`,
name: 'Null Resources Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: null,
});
req.params.file_id = 'another_orphan_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
});

View File

@@ -11,18 +11,25 @@ const { getAppConfig } = require('~/server/services/Config');
* @param {Object} res - Express response object.
* @param {Function} next - Next middleware function.
*
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the domain's email is allowed
* @returns {Promise<void>} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
*/
const checkDomainAllowed = async (req, res, next = () => {}) => {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
});
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
return res.redirect('/login');
} else {
return next();
const checkDomainAllowed = async (req, res, next) => {
try {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
});
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
res.redirect('/login');
return;
}
next();
} catch (error) {
logger.error('[checkDomainAllowed] Error checking domain:', error);
res.redirect('/login');
}
};

View File

@@ -1,6 +1,5 @@
const validatePasswordReset = require('./validatePasswordReset');
const validateRegistration = require('./validateRegistration');
const validateImageRequest = require('./validateImageRequest');
const buildEndpointOption = require('./buildEndpointOption');
const validateMessageReq = require('./validateMessageReq');
const checkDomainAllowed = require('./checkDomainAllowed');
@@ -50,6 +49,5 @@ module.exports = {
validateMessageReq,
buildEndpointOption,
validateRegistration,
validateImageRequest,
validatePasswordReset,
};

View File

@@ -1,14 +1,14 @@
const jwt = require('jsonwebtoken');
const validateImageRequest = require('~/server/middleware/validateImageRequest');
const { isEnabled } = require('@librechat/api');
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
jest.mock('~/server/services/Config/app', () => ({
getAppConfig: jest.fn(),
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
}));
describe('validateImageRequest middleware', () => {
let req, res, next;
let req, res, next, validateImageRequest;
const validObjectId = '65cfb246f7ecadb8b1e8036b';
const { getAppConfig } = require('~/server/services/Config/app');
beforeEach(() => {
jest.clearAllMocks();
@@ -22,116 +22,278 @@ describe('validateImageRequest middleware', () => {
};
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
process.env.OPENID_REUSE_TOKENS = 'false';
// Mock getAppConfig to return secureImageLinks: true by default
getAppConfig.mockResolvedValue({
secureImageLinks: true,
});
// Default: OpenID token reuse disabled
isEnabled.mockReturnValue(false);
});
afterEach(() => {
jest.clearAllMocks();
});
test('should call next() if secureImageLinks is false', async () => {
getAppConfig.mockResolvedValue({
secureImageLinks: false,
describe('Factory function', () => {
test('should return a pass-through middleware if secureImageLinks is false', async () => {
const middleware = createValidateImageRequest(false);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return validation middleware if secureImageLinks is true', async () => {
validateImageRequest = createValidateImageRequest(true);
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 401 if refresh token is not provided', async () => {
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
describe('Standard LibreChat token flow', () => {
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
});
test('should return 403 if refresh token is invalid', async () => {
req.headers.cookie = 'refreshToken=invalid-token';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 401 if refresh token is not provided', async () => {
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
test('should return 403 if refresh token is expired', async () => {
const expiredToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 for invalid ObjectId format', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
// File traversal tests
test('should prevent file traversal attempts', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
`/images/${validObjectId}/../../../etc/passwd`,
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
];
for (const attempt of traversalAttempts) {
req.originalUrl = attempt;
test('should return 403 if refresh token is invalid', async () => {
req.headers.cookie = 'refreshToken=invalid-token';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
}
});
test('should return 403 if refresh token is expired', async () => {
const expiredToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should allow agent avatar pattern for any valid ObjectId', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent file traversal attempts', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
`/images/${validObjectId}/../../../etc/passwd`,
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
];
for (const attempt of traversalAttempts) {
req.originalUrl = attempt;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
// Reset mocks for next iteration
res.status = jest.fn().mockReturnThis();
res.send = jest.fn();
}
});
test('should handle URL encoded characters in valid paths', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
test('should handle URL encoded characters in valid paths', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
describe('OpenID token flow', () => {
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
// Enable OpenID token reuse
isEnabled.mockReturnValue(true);
process.env.OPENID_REUSE_TOKENS = 'true';
});
test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should validate JWT-signed user ID for OpenID flow', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid JWT-signed user ID', async () => {
req.headers.cookie =
'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 for expired JWT-signed user ID', async () => {
const expiredSignedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should validate image path against JWT-signed user ID', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
const differentObjectId = '65cfb246f7ecadb8b1e8036c';
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = `/images/${differentObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should allow agent avatars in OpenID flow', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('Security edge cases', () => {
let validToken;
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
});
test('should handle very long image filenames', async () => {
const longFilename = 'a'.repeat(1000) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with maximum practical length', async () => {
// Most browsers support URLs up to ~2000 characters
const longFilename = 'x'.repeat(1900) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should accept URLs just under the 2048 limit', async () => {
// Create a URL exactly 2047 characters long
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
const filenameLength = 2047 - baseLength;
const filename = 'a'.repeat(filenameLength) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${filename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle malformed URL encoding gracefully', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should reject URLs with null bytes', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with repeated slashes', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}//test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should reject extremely long URLs as potential DoS', async () => {
// Create a URL longer than 2048 characters
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
// Verify our test URL is actually too long
expect(req.originalUrl.length).toBeGreaterThan(2048);
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
});
});

View File

@@ -1,7 +1,7 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getAppConfig } = require('~/server/services/Config/app');
const OBJECT_ID_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
@@ -22,50 +22,129 @@ function isValidObjectId(id) {
}
/**
* Middleware to validate image request.
* Must be set by `secureImageLinks` via custom config file.
* Validates a LibreChat refresh token
* @param {string} refreshToken - The refresh token to validate
* @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
*/
async function validateImageRequest(req, res, next) {
const appConfig = await getAppConfig({ role: req.user?.role });
if (!appConfig.secureImageLinks) {
return next();
}
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
if (!refreshToken) {
logger.warn('[validateImageRequest] Refresh token not provided');
return res.status(401).send('Unauthorized');
}
let payload;
function validateToken(refreshToken) {
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
if (!isValidObjectId(payload.id)) {
return { valid: false, error: 'Invalid User ID' };
}
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < currentTimeInSeconds) {
return { valid: false, error: 'Refresh token expired' };
}
return { valid: true, userId: payload.id };
} catch (err) {
logger.warn('[validateImageRequest]', err);
return res.status(403).send('Access Denied');
}
if (!isValidObjectId(payload.id)) {
logger.warn('[validateImageRequest] Invalid User ID');
return res.status(403).send('Access Denied');
}
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < currentTimeInSeconds) {
logger.warn('[validateImageRequest] Refresh token expired');
return res.status(403).send('Access Denied');
}
const fullPath = decodeURIComponent(req.originalUrl);
const pathPattern = new RegExp(`^/images/${payload.id}/[^/]+$`);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
next();
} else {
logger.warn('[validateImageRequest] Invalid image path');
res.status(403).send('Access Denied');
logger.warn('[validateToken]', err);
return { valid: false, error: 'Invalid token' };
}
}
module.exports = validateImageRequest;
/**
* Factory to create the `validateImageRequest` middleware with configured secureImageLinks
* @param {boolean} [secureImageLinks] - Whether secure image links are enabled
*/
function createValidateImageRequest(secureImageLinks) {
if (!secureImageLinks) {
return (_req, _res, next) => next();
}
/**
* Middleware to validate image request.
* Supports both LibreChat refresh tokens and OpenID JWT tokens.
* Must be set by `secureImageLinks` via custom config file.
*/
return async function validateImageRequest(req, res, next) {
try {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
logger.warn('[validateImageRequest] No cookies provided');
return res.status(401).send('Unauthorized');
}
const parsedCookies = cookies.parse(cookieHeader);
const refreshToken = parsedCookies.refreshToken;
if (!refreshToken) {
logger.warn('[validateImageRequest] Token not provided');
return res.status(401).send('Unauthorized');
}
const tokenProvider = parsedCookies.token_provider;
let userIdForPath;
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId) {
logger.warn('[validateImageRequest] No OpenID user ID cookie found');
return res.status(403).send('Access Denied');
}
const validationResult = validateToken(openidUserId);
if (!validationResult.valid) {
logger.warn(`[validateImageRequest] ${validationResult.error}`);
return res.status(403).send('Access Denied');
}
userIdForPath = validationResult.userId;
} else {
const validationResult = validateToken(refreshToken);
if (!validationResult.valid) {
logger.warn(`[validateImageRequest] ${validationResult.error}`);
return res.status(403).send('Access Denied');
}
userIdForPath = validationResult.userId;
}
if (!userIdForPath) {
logger.warn('[validateImageRequest] No user ID available for path validation');
return res.status(403).send('Access Denied');
}
const MAX_URL_LENGTH = 2048;
if (req.originalUrl.length > MAX_URL_LENGTH) {
logger.warn('[validateImageRequest] URL too long');
return res.status(403).send('Access Denied');
}
if (req.originalUrl.includes('\x00')) {
logger.warn('[validateImageRequest] URL contains null byte');
return res.status(403).send('Access Denied');
}
let fullPath;
try {
fullPath = decodeURIComponent(req.originalUrl);
} catch {
logger.warn('[validateImageRequest] Invalid URL encoding');
return res.status(403).send('Access Denied');
}
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
if (agentAvatarPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
return next();
}
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
next();
} else {
logger.warn('[validateImageRequest] Invalid image path');
res.status(403).send('Access Denied');
}
} catch (error) {
logger.error('[validateImageRequest] Error:', error);
res.status(500).send('Internal Server Error');
}
};
}
module.exports = createValidateImageRequest;

View File

@@ -11,6 +11,9 @@ jest.mock('@librechat/api', () => ({
completeOAuthFlow: jest.fn(),
generateFlowId: jest.fn(),
},
MCPTokenStorage: {
storeTokens: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
}));
@@ -47,8 +50,8 @@ jest.mock('~/server/services/Config', () => ({
loadCustomConfig: jest.fn(),
}));
jest.mock('~/server/services/Config/mcpToolsCache', () => ({
updateMCPUserTools: jest.fn(),
jest.mock('~/server/services/Config/mcp', () => ({
updateMCPServerTools: jest.fn(),
}));
jest.mock('~/server/services/MCP', () => ({
@@ -234,7 +237,7 @@ describe('MCP Routes', () => {
});
describe('GET /:serverName/oauth/callback', () => {
const { MCPOAuthHandler } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const { getLogStores } = require('~/cache');
it('should redirect to error page when OAuth error is received', async () => {
@@ -280,6 +283,7 @@ describe('MCP Routes', () => {
it('should handle OAuth callback successfully', async () => {
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
@@ -295,6 +299,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@@ -332,11 +337,24 @@ describe('MCP Routes', () => {
'test-auth-code',
mockFlowManager,
);
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'test-user-id',
serverName: 'test-server',
tokens: mockTokens,
clientInfo: mockFlowState.clientInfo,
metadata: mockFlowState.metadata,
}),
);
const storeInvocation = MCPTokenStorage.storeTokens.mock.invocationCallOrder[0];
const connectInvocation = mockMcpManager.getUserConnection.mock.invocationCallOrder[0];
expect(storeInvocation).toBeLessThan(connectInvocation);
expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
'tool-flow-123',
'mcp_oauth',
mockTokens,
);
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
});
it('should redirect to error page when callback processing fails', async () => {
@@ -354,6 +372,7 @@ describe('MCP Routes', () => {
it('should handle system-level OAuth completion', async () => {
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
@@ -369,6 +388,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@@ -379,11 +399,13 @@ describe('MCP Routes', () => {
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
});
it('should handle reconnection failure after OAuth', async () => {
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
@@ -399,6 +421,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@@ -418,6 +441,46 @@ describe('MCP Routes', () => {
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
});
it('should redirect to error page if token storage fails', async () => {
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: { toolFlowId: 'tool-flow-123' },
clientInfo: {},
codeVerifier: 'test-verifier',
};
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockMcpManager = {
getUserConnection: jest.fn(),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
});
});
@@ -778,10 +841,10 @@ describe('MCP Routes', () => {
require('~/cache').getLogStores.mockReturnValue({});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
updateMCPServerTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
@@ -836,10 +899,10 @@ describe('MCP Routes', () => {
]);
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
updateMCPServerTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
@@ -1143,7 +1206,11 @@ describe('MCP Routes', () => {
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
const { MCPOAuthHandler } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
};
MCPOAuthHandler.getFlowState = jest.fn().mockResolvedValue({
id: 'test-flow-id',
userId: 'test-user-id',
@@ -1155,6 +1222,8 @@ describe('MCP Routes', () => {
clientInfo: {},
codeVerifier: 'test-verifier',
});
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
const mockFlowManager = {
completeFlow: jest.fn(),
@@ -1179,6 +1248,11 @@ describe('MCP Routes', () => {
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
const { getCachedTools } = require('~/server/services/Config');
getCachedTools.mockResolvedValue(null);
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
};
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
@@ -1191,6 +1265,15 @@ describe('MCP Routes', () => {
completeFlow: jest.fn(),
};
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.getFlowState.mockResolvedValue({
serverName: 'test-server',
userId: 'test-user-id',
metadata: { serverUrl: 'https://example.com', oauth: {} },
clientInfo: {},
codeVerifier: 'test-verifier',
});
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({

View File

@@ -122,9 +122,11 @@ router.get('/', async function (req, res) {
payload.minPasswordLength = minPasswordLength;
}
payload.mcpServers = {};
const getMCPServers = () => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
@@ -133,6 +135,9 @@ router.get('/', async function (req, res) {
if (!mcpServers) return;
const oauthServers = mcpManager.getOAuthServers();
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,

View File

@@ -4,9 +4,13 @@ const { sleep } = require('@librechat/agents');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const {
createImportLimiters,
createForkLimiters,
configMiddleware,
} = require('~/server/middleware');
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { importConversations } = require('~/server/utils/import');
@@ -171,6 +175,7 @@ router.post(
'/import',
importIpLimiter,
importUserLimiter,
configMiddleware,
upload.single('file'),
async (req, res) => {
try {

View File

@@ -31,6 +31,7 @@ const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const { Readable } = require('stream');
const router = express.Router();
@@ -184,6 +185,7 @@ router.delete('/', async (req, res) => {
role: req.user.role,
fileIds: nonOwnedFileIds,
agentId: req.body.agent_id,
isDelete: true,
});
for (const file of nonOwnedFiles) {
@@ -325,11 +327,6 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
res.setHeader('X-File-Metadata', JSON.stringify(file));
};
/** @type {{ body: import('stream').PassThrough } | undefined} */
let passThrough;
/** @type {ReadableStream | undefined} */
let fileStream;
if (checkOpenAIStorage(file.source)) {
req.body = { model: file.model };
const endpointMap = {
@@ -342,12 +339,19 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
overrideEndpoint: endpointMap[file.source],
});
logger.debug(`Downloading file ${file_id} from OpenAI`);
passThrough = await getDownloadStream(file_id, openai);
const passThrough = await getDownloadStream(file_id, openai);
setHeaders();
logger.debug(`File ${file_id} downloaded from OpenAI`);
passThrough.body.pipe(res);
// Handle both Node.js and Web streams
const stream =
passThrough.body && typeof passThrough.body.getReader === 'function'
? Readable.fromWeb(passThrough.body)
: passThrough.body;
stream.pipe(res);
} else {
fileStream = await getDownloadStream(req, file.filepath);
const fileStream = await getDownloadStream(req, file.filepath);
fileStream.on('error', (streamError) => {
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);

View File

@@ -1,19 +1,28 @@
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { MCPOAuthHandler, MCPTokenStorage, getUserMCPAuthMap } = require('@librechat/api');
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getMCPTools } = require('~/server/controllers/mcp');
const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models');
const { getLogStores } = require('~/cache');
const router = Router();
/**
* Get all MCP tools available to the user
* Returns only MCP tools, completely decoupled from regular LibreChat tools
*/
router.get('/tools', requireJwtAuth, async (req, res) => {
return getMCPTools(req, res);
});
/**
* Initiate OAuth flow
* This endpoint is called when the user clicks the auth link in the UI
@@ -121,6 +130,41 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
/** Persist tokens immediately so reconnection uses fresh credentials */
if (flowState?.userId && tokens) {
try {
await MCPTokenStorage.storeTokens({
userId: flowState.userId,
serverName,
tokens,
createToken,
updateToken,
findToken,
clientInfo: flowState.clientInfo,
metadata: flowState.metadata,
});
logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', {
serverName,
userId: flowState.userId,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to store OAuth tokens after callback', error);
throw error;
}
/**
* Clear any cached `mcp_get_tokens` flow result so subsequent lookups
* re-fetch the freshly stored credentials instead of returning stale nulls.
*/
if (typeof flowManager?.deleteFlow === 'function') {
try {
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
} catch (error) {
logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);
}
}
}
try {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
@@ -144,9 +188,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
// clear any reconnection attempts
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const tools = await userConnection.fetchTools();
await updateMCPUserTools({
userId: flowState.userId,
await updateMCPServerTools({
serverName,
tools,
});
@@ -288,9 +335,9 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
const userId = req.user?.id;
if (!user?.id) {
if (!userId) {
return res.status(401).json({ error: 'User not authenticated' });
}
@@ -304,7 +351,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
});
}
await mcpManager.disconnectUserConnection(user.id, serverName);
await mcpManager.disconnectUserConnection(userId, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
@@ -313,14 +360,14 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
userId,
servers: [serverName],
findPluginAuthsByKeys,
});
}
const result = await reinitMCPServer({
req,
userId,
serverName,
userMCPAuthMap,
});

View File

@@ -26,9 +26,12 @@ const domains = {
router.use(logHeaders);
router.use(loginLimiter);
const oauthHandler = async (req, res) => {
const oauthHandler = async (req, res, next) => {
try {
await checkDomainAllowed(req, res);
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
@@ -39,13 +42,14 @@ const oauthHandler = async (req, res) => {
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res);
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
}
res.redirect(domains.client);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
@@ -79,6 +83,7 @@ router.get(
scope: ['openid', 'profile', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -104,6 +109,7 @@ router.get(
profileFields: ['id', 'email', 'name'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -125,6 +131,7 @@ router.get(
session: false,
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -148,6 +155,7 @@ router.get(
scope: ['user:email', 'read:user'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -171,6 +179,7 @@ router.get(
scope: ['identify', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -192,6 +201,7 @@ router.post(
session: false,
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);

View File

@@ -156,7 +156,7 @@ router.get('/all', async (req, res) => {
router.get('/groups', async (req, res) => {
try {
const userId = req.user.id;
const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query;
const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query;
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name,
@@ -171,6 +171,13 @@ router.get('/groups', async (req, res) => {
actualLimit = parseInt(pageSize, 10);
}
if (
actualCursor &&
(actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0)
) {
actualCursor = null;
}
let accessibleIds = await findAccessibleResources({
userId,
role: req.user.role,
@@ -190,6 +197,7 @@ router.get('/groups', async (req, res) => {
publicPromptGroupIds: publiclyAccessibleIds,
});
// Cursor-based pagination only
const result = await getListPromptGroupsByAccess({
accessibleIds: filteredAccessibleIds,
otherParams: filter,
@@ -198,19 +206,21 @@ router.get('/groups', async (req, res) => {
});
if (!result) {
const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit });
const emptyResponse = createEmptyPromptGroupsResponse({
pageNumber: '1',
pageSize: actualLimit,
actualLimit,
});
return res.status(200).send(emptyResponse);
}
const { data: promptGroups = [], has_more = false, after = null } = result;
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
const response = formatPromptGroupsResponse({
promptGroups: groupsWithPublicFlag,
pageNumber,
pageSize,
actualLimit,
pageNumber: '1', // Always 1 for cursor-based pagination
pageSize: actualLimit.toString(),
hasMore: has_more,
after,
});

View File

@@ -33,22 +33,11 @@ let promptRoutes;
let Prompt, PromptGroup, AclEntry, AccessRole, User;
let testUsers, testRoles;
let grantPermission;
let currentTestUser; // Track current user for middleware
// Helper function to set user in middleware
function setTestUser(app, user) {
app.use((req, res, next) => {
req.user = {
...(user.toObject ? user.toObject() : user),
id: user.id || user._id.toString(),
_id: user._id,
name: user.name,
role: user.role,
};
if (user.role === SystemRoles.ADMIN) {
console.log('Setting admin user with role:', req.user.role);
}
next();
});
currentTestUser = user;
}
beforeAll(async () => {
@@ -75,14 +64,35 @@ beforeAll(async () => {
app = express();
app.use(express.json());
// Mock authentication middleware - default to owner
setTestUser(app, testUsers.owner);
// Add user middleware before routes
app.use((req, res, next) => {
if (currentTestUser) {
req.user = {
...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser),
id: currentTestUser._id.toString(),
_id: currentTestUser._id,
name: currentTestUser.name,
role: currentTestUser.role,
};
}
next();
});
// Import routes after mocks are set up
// Set default user
currentTestUser = testUsers.owner;
// Import routes after middleware is set up
promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
});
afterEach(() => {
// Always reset to owner user after each test for isolation
if (currentTestUser !== testUsers.owner) {
currentTestUser = testUsers.owner;
}
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
@@ -116,36 +126,26 @@ async function setupTestData() {
// Create test users
testUsers = {
owner: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Owner',
email: 'owner@example.com',
role: SystemRoles.USER,
}),
viewer: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Viewer',
email: 'viewer@example.com',
role: SystemRoles.USER,
}),
editor: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Editor',
email: 'editor@example.com',
role: SystemRoles.USER,
}),
noAccess: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'No Access',
email: 'noaccess@example.com',
role: SystemRoles.USER,
}),
admin: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Admin',
email: 'admin@example.com',
role: SystemRoles.ADMIN,
@@ -181,8 +181,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should have routes loaded', async () => {
// This should at least not crash
const response = await request(app).get('/api/prompts/test-404');
console.log('Test 404 response status:', response.status);
console.log('Test 404 response body:', response.body);
// We expect a 401 or 404, not 500
expect(response.status).not.toBe(500);
});
@@ -207,12 +206,6 @@ describe('Prompt Routes - ACL Permissions', () => {
const response = await request(app).post('/api/prompts').send(promptData);
if (response.status !== 200) {
console.log('POST /api/prompts error status:', response.status);
console.log('POST /api/prompts error body:', response.body);
console.log('Console errors:', consoleErrorSpy.mock.calls);
}
expect(response.status).toBe(200);
expect(response.body.prompt).toBeDefined();
expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt);
@@ -318,29 +311,8 @@ describe('Prompt Routes - ACL Permissions', () => {
});
it('should allow admin access without explicit permissions', async () => {
// First, reset the app to remove previous middleware
app = express();
app.use(express.json());
// Set admin user BEFORE adding routes
app.use((req, res, next) => {
req.user = {
...testUsers.admin.toObject(),
id: testUsers.admin._id.toString(),
_id: testUsers.admin._id,
name: testUsers.admin.name,
role: testUsers.admin.role,
};
next();
});
// Now add the routes
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
console.log('Admin user:', testUsers.admin);
console.log('Admin role:', testUsers.admin.role);
console.log('SystemRoles.ADMIN:', SystemRoles.ADMIN);
// Set admin user
setTestUser(app, testUsers.admin);
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
@@ -432,21 +404,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.editor._id,
});
// Recreate app with viewer user
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.viewer.toObject(),
id: testUsers.viewer._id.toString(),
_id: testUsers.viewer._id,
name: testUsers.viewer.name,
role: testUsers.viewer.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
// Set viewer user
setTestUser(app, testUsers.viewer);
await request(app)
.delete(`/api/prompts/${authorPrompt._id}`)
@@ -499,21 +458,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.owner._id,
});
// Recreate app to ensure fresh middleware
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.owner.toObject(),
id: testUsers.owner._id.toString(),
_id: testUsers.owner._id,
name: testUsers.owner.name,
role: testUsers.owner.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
// Ensure owner user
setTestUser(app, testUsers.owner);
const response = await request(app)
.patch(`/api/prompts/${testPrompt._id}/tags/production`)
@@ -537,21 +483,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.owner._id,
});
// Recreate app with viewer user
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.viewer.toObject(),
id: testUsers.viewer._id.toString(),
_id: testUsers.viewer._id,
name: testUsers.viewer.name,
role: testUsers.viewer.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
// Set viewer user
setTestUser(app, testUsers.viewer);
await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
@@ -610,4 +543,305 @@ describe('Prompt Routes - ACL Permissions', () => {
expect(response.body._id).toBe(publicPrompt._id.toString());
});
});
describe('Pagination', () => {
beforeEach(async () => {
// Create multiple prompt groups for pagination testing
const groups = [];
for (let i = 0; i < 15; i++) {
const group = await PromptGroup.create({
name: `Test Group ${i + 1}`,
category: 'pagination-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000), // Stagger updatedAt for consistent ordering
});
groups.push(group);
// Grant owner permissions on each group
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
});
afterEach(async () => {
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('should correctly indicate hasMore when there are more pages', async () => {
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10' })
.expect(200);
expect(response.body.promptGroups).toHaveLength(10);
expect(response.body.has_more).toBe(true);
expect(response.body.after).toBeTruthy();
// Since has_more is true, pages should be a high number (9999 in our fix)
expect(parseInt(response.body.pages)).toBeGreaterThan(1);
});
it('should correctly indicate no more pages on the last page', async () => {
// First get the cursor for page 2
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10' })
.expect(200);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
// Now fetch the second page using the cursor
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after })
.expect(200);
expect(response.body.promptGroups).toHaveLength(5); // 15 total, 10 on page 1, 5 on page 2
expect(response.body.has_more).toBe(false);
});
it('should support cursor-based pagination', async () => {
// First page
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(5);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
// Second page using cursor
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(5);
expect(secondPage.body.has_more).toBe(true);
expect(secondPage.body.after).toBeTruthy();
// Verify different groups
const firstPageIds = firstPage.body.promptGroups.map((g) => g._id);
const secondPageIds = secondPage.body.promptGroups.map((g) => g._id);
expect(firstPageIds).not.toEqual(secondPageIds);
});
it('should paginate correctly with category filtering', async () => {
// Create groups with different categories
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 8 groups with category 'test-cat-1'
for (let i = 0; i < 8; i++) {
const group = await PromptGroup.create({
name: `Category 1 Group ${i + 1}`,
category: 'test-cat-1',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create 7 groups with category 'test-cat-2'
for (let i = 0; i < 7; i++) {
const group = await PromptGroup.create({
name: `Category 2 Group ${i + 1}`,
category: 'test-cat-2',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 8) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with category filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', category: 'test-cat-1' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(5);
expect(firstPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(3); // 8 total, 5 on page 1, 3 on page 2
expect(secondPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
expect(secondPage.body.has_more).toBe(false);
});
it('should paginate correctly with name/keyword filtering', async () => {
// Create groups with specific names
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 12 groups with 'Search' in the name
for (let i = 0; i < 12; i++) {
const group = await PromptGroup.create({
name: `Search Test Group ${i + 1}`,
category: 'search-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create 5 groups without 'Search' in the name
for (let i = 0; i < 5; i++) {
const group = await PromptGroup.create({
name: `Other Group ${i + 1}`,
category: 'other-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 12) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with name filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', name: 'Search' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(10);
expect(firstPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(2); // 12 total, 10 on page 1, 2 on page 2
expect(secondPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
expect(secondPage.body.has_more).toBe(false);
});
it('should paginate correctly with combined filters', async () => {
// Create groups with various combinations
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 6 groups matching both category and name filters
for (let i = 0; i < 6; i++) {
const group = await PromptGroup.create({
name: `API Test Group ${i + 1}`,
category: 'api-category',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create groups that only match one filter
for (let i = 0; i < 4; i++) {
const group = await PromptGroup.create({
name: `API Other Group ${i + 1}`,
category: 'other-category',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 6) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with both filters
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', name: 'API', category: 'api-category' })
.expect(200);
expect(response.body.promptGroups).toHaveLength(5);
expect(
response.body.promptGroups.every(
(g) => g.name.includes('API') && g.category === 'api-category',
),
).toBe(true);
expect(response.body.has_more).toBe(true);
expect(response.body.after).toBeTruthy();
// Page 2
const page2 = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
.expect(200);
expect(page2.body.promptGroups).toHaveLength(1); // 6 total, 5 on page 1, 1 on page 2
expect(page2.body.has_more).toBe(false);
});
});
});

View File

@@ -49,6 +49,7 @@ const AppService = async () => {
enabled: isEnabled(process.env.CHECK_BALANCE),
startBalance: startBalance ? parseInt(startBalance, 10) : undefined,
};
const transactions = config.transactions ?? configDefaults.transactions;
const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType;
process.env.CDN_PROVIDER = fileStrategy;
@@ -84,6 +85,7 @@ const AppService = async () => {
memory,
speech,
balance,
transactions,
mcpConfig,
webSearch,
fileStrategy,

View File

@@ -152,6 +152,7 @@ describe('AppService', () => {
webSearch: expect.objectContaining({
safeSearch: 1,
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
cohereApiKey: '${COHERE_API_KEY}',
serperApiKey: '${SERPER_API_KEY}',
searxngApiKey: '${SEARXNG_API_KEY}',

View File

@@ -3,12 +3,12 @@ const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, checkEmailConfig } = require('@librechat/api');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
findToken,
createUser,
updateUser,
findToken,
countUsers,
getUserById,
findSession,
@@ -181,6 +181,14 @@ const registerUser = async (user, additionalData = {}) => {
let newUserId;
try {
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
@@ -195,14 +203,6 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 200, message: genericVerificationMessage };
}
const appConfig = await getAppConfig({ role: user.role });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
@@ -252,6 +252,13 @@ const registerUser = async (user, additionalData = {}) => {
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return error;
}
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
@@ -350,23 +357,18 @@ const resetPassword = async (userId, token, password) => {
/**
* Set Auth Tokens
*
* @param {String | ObjectId} userId
* @param {Object} res
* @param {String} sessionId
* @param {ServerResponse} res
* @param {ISession | null} [session=null]
* @returns
*/
const setAuthTokens = async (userId, res, sessionId = null) => {
const setAuthTokens = async (userId, res, _session = null) => {
try {
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let session = _session;
let refreshToken;
let refreshTokenExpires;
if (sessionId) {
session = await findSession({ sessionId: sessionId }, { lean: false });
if (session && session._id && session.expiration != null) {
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
@@ -376,6 +378,9 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
refreshTokenExpires = session.expiration.getTime();
}
const user = await getUserById(userId);
const token = await generateToken(user);
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,
@@ -402,9 +407,10 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
* @param {Object} res - response object
* @param {string} [userId] - Optional MongoDB user ID for image path validation
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res) => {
const setOpenIDAuthTokens = (tokenset, res, userId) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@@ -435,6 +441,18 @@ const setOpenIDAuthTokens = (tokenset, res) => {
secure: isProduction,
sameSite: 'strict',
});
if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
/** JWT-signed user ID cookie for image path validation when OPENID_REUSE_TOKENS is enabled */
const signedUserId = jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, {
expiresIn: expiryInMilliseconds / 1000,
});
res.cookie('openid_user_id', signedUserId, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
}
return tokenset.access_token;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
@@ -452,7 +470,7 @@ const setOpenIDAuthTokens = (tokenset, res) => {
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await deleteTokens(email);
await deleteTokens({ email });
const user = await findUser({ email }, 'email _id name');
if (!user) {

View File

@@ -4,6 +4,8 @@ const AppService = require('~/server/services/AppService');
const { setCachedTools } = require('./getCachedTools');
const getLogStores = require('~/cache/getLogStores');
const BASE_CONFIG_KEY = '_BASE_';
/**
* Get the app configuration based on user context
* @param {Object} [options]
@@ -14,8 +16,8 @@ const getLogStores = require('~/cache/getLogStores');
async function getAppConfig(options = {}) {
const { role, refresh } = options;
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cacheKey = role ? `${CacheKeys.APP_CONFIG}:${role}` : CacheKeys.APP_CONFIG;
const cache = getLogStores(CacheKeys.APP_CONFIG);
const cacheKey = role ? role : BASE_CONFIG_KEY;
if (!refresh) {
const cached = await cache.get(cacheKey);
@@ -24,7 +26,7 @@ async function getAppConfig(options = {}) {
}
}
let baseConfig = await cache.get(CacheKeys.APP_CONFIG);
let baseConfig = await cache.get(BASE_CONFIG_KEY);
if (!baseConfig) {
logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...');
baseConfig = await AppService();
@@ -34,10 +36,10 @@ async function getAppConfig(options = {}) {
}
if (baseConfig.availableTools) {
await setCachedTools(baseConfig.availableTools, { isGlobal: true });
await setCachedTools(baseConfig.availableTools);
}
await cache.set(CacheKeys.APP_CONFIG, baseConfig);
await cache.set(BASE_CONFIG_KEY, baseConfig);
}
// For now, return the base config

View File

@@ -3,89 +3,32 @@ const getLogStores = require('~/cache/getLogStores');
/**
* Cache key generators for different tool access patterns
* These will support future permission-based caching
*/
const ToolCacheKeys = {
/** Global tools available to all users */
GLOBAL: 'tools:global',
/** Tools available to a specific user */
USER: (userId) => `tools:user:${userId}`,
/** Tools available to a specific role */
ROLE: (roleId) => `tools:role:${roleId}`,
/** Tools available to a specific group */
GROUP: (groupId) => `tools:group:${groupId}`,
/** Combined effective tools for a user (computed from all sources) */
EFFECTIVE: (userId) => `tools:effective:${userId}`,
/** MCP tools cached by server name */
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
};
/**
* Retrieves available tools from cache
* @function getCachedTools
* @param {Object} options - Options for retrieving tools
* @param {string} [options.userId] - User ID for user-specific tools
* @param {string[]} [options.roleIds] - Role IDs for role-based tools
* @param {string[]} [options.groupIds] - Group IDs for group-based tools
* @param {boolean} [options.includeGlobal=true] - Whether to include global tools
* @param {string} [options.serverName] - MCP server name to get cached tools for
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options;
const { serverName } = options;
// For now, return global tools (current behavior)
// This will be expanded to merge tools from different sources
if (!userId && includeGlobal) {
return await cache.get(ToolCacheKeys.GLOBAL);
// Return MCP server-specific tools if requested
if (serverName) {
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
}
// Future implementation will merge tools from multiple sources
// based on user permissions, roles, and groups
if (userId) {
/** @type {LCAvailableTools | null} Check if we have pre-computed effective tools for this user */
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
if (effectiveTools) {
return effectiveTools;
}
/** @type {LCAvailableTools | null} Otherwise, compute from individual sources */
const toolSources = [];
if (includeGlobal) {
const globalTools = await cache.get(ToolCacheKeys.GLOBAL);
if (globalTools) {
toolSources.push(globalTools);
}
}
// User-specific tools
const userTools = await cache.get(ToolCacheKeys.USER(userId));
if (userTools) {
toolSources.push(userTools);
}
// Role-based tools
for (const roleId of roleIds) {
const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId));
if (roleTools) {
toolSources.push(roleTools);
}
}
// Group-based tools
for (const groupId of groupIds) {
const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId));
if (groupTools) {
toolSources.push(groupTools);
}
}
// Merge all tool sources (for now, simple merge - future will handle conflicts)
if (toolSources.length > 0) {
return mergeToolSources(toolSources);
}
}
return null;
// Default to global tools
return await cache.get(ToolCacheKeys.GLOBAL);
}
/**
@@ -93,49 +36,34 @@ async function getCachedTools(options = {}) {
* @function setCachedTools
* @param {Object} tools - The tools object to cache
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific tools
* @param {string} [options.roleId] - Role ID for role-based tools
* @param {string} [options.groupId] - Group ID for group-based tools
* @param {boolean} [options.isGlobal=false] - Whether these are global tools
* @param {string} [options.serverName] - MCP server name for server-specific tools
* @param {number} [options.ttl] - Time to live in milliseconds
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, isGlobal = false, ttl } = options;
const { serverName, ttl } = options;
let cacheKey;
if (isGlobal || (!userId && !roleId && !groupId)) {
cacheKey = ToolCacheKeys.GLOBAL;
} else if (userId) {
cacheKey = ToolCacheKeys.USER(userId);
} else if (roleId) {
cacheKey = ToolCacheKeys.ROLE(roleId);
} else if (groupId) {
cacheKey = ToolCacheKeys.GROUP(groupId);
// Cache by MCP server if specified
if (serverName) {
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
}
if (!cacheKey) {
throw new Error('Invalid cache key options provided');
}
return await cache.set(cacheKey, tools, ttl);
// Default to global cache
return await cache.set(ToolCacheKeys.GLOBAL, tools, ttl);
}
/**
* Invalidates cached tools
* @function invalidateCachedTools
* @param {Object} options - Options for invalidating tools
* @param {string} [options.userId] - User ID to invalidate
* @param {string} [options.roleId] - Role ID to invalidate
* @param {string} [options.groupId] - Group ID to invalidate
* @param {string} [options.serverName] - MCP server name to invalidate
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
* @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options;
const { serverName, invalidateGlobal = false } = options;
const keysToDelete = [];
@@ -143,116 +71,34 @@ async function invalidateCachedTools(options = {}) {
keysToDelete.push(ToolCacheKeys.GLOBAL);
}
if (userId) {
keysToDelete.push(ToolCacheKeys.USER(userId));
if (invalidateEffective) {
keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId));
}
}
if (roleId) {
keysToDelete.push(ToolCacheKeys.ROLE(roleId));
// TODO: In future, invalidate all users with this role
}
if (groupId) {
keysToDelete.push(ToolCacheKeys.GROUP(groupId));
// TODO: In future, invalidate all users in this group
if (serverName) {
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
}
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
/**
* Computes and caches effective tools for a user
* @function computeEffectiveTools
* @param {string} userId - The user ID
* @param {Object} context - Context containing user's roles and groups
* @param {string[]} [context.roleIds=[]] - User's role IDs
* @param {string[]} [context.groupIds=[]] - User's group IDs
* @param {number} [ttl] - Time to live for the computed result
* @returns {Promise<Object>} The computed effective tools
* Gets MCP tools for a specific server from cache or merges with global tools
* @function getMCPServerTools
* @param {string} serverName - The MCP server name
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
*/
async function computeEffectiveTools(userId, context = {}, ttl) {
const { roleIds = [], groupIds = [] } = context;
async function getMCPServerTools(serverName) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
// Get all tool sources
const tools = await getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
if (tools) {
// Cache the computed result
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl);
if (serverTools) {
return serverTools;
}
return tools;
}
/**
* Merges multiple tool sources into a single tools object
* @function mergeToolSources
* @param {Object[]} sources - Array of tool objects to merge
* @returns {Object} Merged tools object
*/
function mergeToolSources(sources) {
// For now, simple merge that combines all tools
// Future implementation will handle:
// - Permission precedence (deny > allow)
// - Tool property conflicts
// - Metadata merging
const merged = {};
for (const source of sources) {
if (!source || typeof source !== 'object') {
continue;
}
for (const [toolId, toolConfig] of Object.entries(source)) {
// Simple last-write-wins for now
// Future: merge based on permission levels
merged[toolId] = toolConfig;
}
}
return merged;
}
/**
* Middleware-friendly function to get tools for a request
* @function getToolsForRequest
* @param {Object} req - Express request object
* @returns {Promise<Object|null>} Available tools for the request
*/
async function getToolsForRequest(req) {
const userId = req.user?.id;
// For now, return global tools if no user
if (!userId) {
return getCachedTools({ includeGlobal: true });
}
// Future: Extract roles and groups from req.user
const roleIds = req.user?.roles || [];
const groupIds = req.user?.groups || [];
return getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
return null;
}
module.exports = {
ToolCacheKeys,
getCachedTools,
setCachedTools,
getToolsForRequest,
getMCPServerTools,
invalidateCachedTools,
computeEffectiveTools,
};

View File

@@ -1,7 +1,7 @@
const appConfig = require('./app');
const mcpToolsCache = require('./mcp');
const { config } = require('./EndpointService');
const getCachedTools = require('./getCachedTools');
const mcpToolsCache = require('./mcpToolsCache');
const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels');
const loadDefaultModels = require('./loadDefaultModels');

View File

@@ -119,10 +119,6 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
.filter((endpoint) => endpoint.customParams)
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
if (customConfig.cache) {
const cache = getLogStores(CacheKeys.STATIC_CONFIG);
await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig);
}
if (result.data.modelSpecs) {
customConfig.modelSpecs = result.data.modelSpecs;

View File

@@ -48,16 +48,11 @@ const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores');
describe('loadCustomConfig', () => {
const mockSet = jest.fn();
const mockCache = { set: mockSet };
beforeEach(() => {
jest.resetAllMocks();
delete process.env.CONFIG_PATH;
getLogStores.mockReturnValue(mockCache);
});
it('should return null and log error if remote config fetch fails', async () => {
@@ -94,7 +89,6 @@ describe('loadCustomConfig', () => {
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null and log if config schema validation fails', async () => {
@@ -134,7 +128,6 @@ describe('loadCustomConfig', () => {
axios.get.mockResolvedValue({ data: mockConfig });
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null if the remote config file is not found', async () => {
@@ -168,7 +161,6 @@ describe('loadCustomConfig', () => {
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
expect(mockSet).not.toHaveBeenCalled();
});
it('should log the loaded custom config', async () => {

View File

@@ -0,0 +1,91 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getCachedTools, setCachedTools } = require('./getCachedTools');
const { getLogStores } = require('~/cache');
/**
* Updates MCP tools in the cache for a specific server
* @param {Object} params - Parameters for updating MCP tools
* @param {string} params.serverName - MCP server name
* @param {Array} params.tools - Array of tool objects from MCP server
* @returns {Promise<LCAvailableTools>}
*/
async function updateMCPServerTools({ serverName, tools }) {
try {
const serverTools = {};
const mcpDelimiter = Constants.mcp_delimiter;
for (const tool of tools) {
const name = `${tool.name}${mcpDelimiter}${serverName}`;
serverTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(serverTools, { serverName });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
return serverTools;
} catch (error) {
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
throw error;
}
}
/**
* Merges app-level tools with global tools
* @param {import('@librechat/api').LCAvailableTools} appTools
* @returns {Promise<void>}
*/
async function mergeAppTools(appTools) {
try {
const count = Object.keys(appTools).length;
if (!count) {
return;
}
const cachedTools = await getCachedTools();
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools);
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {
logger.error('Failed to merge app-level tools:', error);
throw error;
}
}
/**
* Caches MCP server tools (no longer merges with global)
* @param {object} params
* @param {string} params.serverName
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
* @returns {Promise<void>}
*/
async function cacheMCPServerTools({ serverName, serverTools }) {
try {
const count = Object.keys(serverTools).length;
if (!count) {
return;
}
// Only cache server-specific tools, no merging with global
await setCachedTools(serverTools, { serverName });
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
} catch (error) {
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
throw error;
}
}
module.exports = {
mergeAppTools,
cacheMCPServerTools,
updateMCPServerTools,
};

View File

@@ -1,143 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getCachedTools, setCachedTools } = require('./getCachedTools');
const { getLogStores } = require('~/cache');
/**
* Updates MCP tools in the cache for a specific server and user
* @param {Object} params - Parameters for updating MCP tools
* @param {string} params.userId - User ID
* @param {string} params.serverName - MCP server name
* @param {Array} params.tools - Array of tool objects from MCP server
* @returns {Promise<LCAvailableTools>}
*/
async function updateMCPUserTools({ userId, serverName, tools }) {
try {
const userTools = await getCachedTools({ userId });
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`);
return userTools;
} catch (error) {
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
throw error;
}
}
/**
* Merges app-level tools with global tools
* @param {import('@librechat/api').LCAvailableTools} appTools
* @returns {Promise<void>}
*/
async function mergeAppTools(appTools) {
try {
const count = Object.keys(appTools).length;
if (!count) {
return;
}
const cachedTools = await getCachedTools({ includeGlobal: true });
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools, { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {
logger.error('Failed to merge app-level tools:', error);
throw error;
}
}
/**
* Merges user-level tools with global tools
* @param {object} params
* @param {string} params.userId
* @param {Record<string, FunctionTool>} params.cachedUserTools
* @param {import('@librechat/api').LCAvailableTools} params.userTools
* @returns {Promise<void>}
*/
async function mergeUserTools({ userId, cachedUserTools, userTools }) {
try {
if (!userId) {
return;
}
const count = Object.keys(userTools).length;
if (!count) {
return;
}
const cachedTools = cachedUserTools ?? (await getCachedTools({ userId }));
const mergedTools = { ...cachedTools, ...userTools };
await setCachedTools(mergedTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} user-level tools`);
} catch (error) {
logger.error('Failed to merge user-level tools:', error);
throw error;
}
}
/**
* Clears all MCP tools for a specific server
* @param {Object} params - Parameters for clearing MCP tools
* @param {string} [params.userId] - User ID (if clearing user-specific tools)
* @param {string} params.serverName - MCP server name
* @returns {Promise<void>}
*/
async function clearMCPServerTools({ userId, serverName }) {
try {
const tools = await getCachedTools({ userId, includeGlobal: !userId });
// Remove all tools for this server
const mcpDelimiter = Constants.mcp_delimiter;
let removedCount = 0;
for (const key of Object.keys(tools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete tools[key];
removedCount++;
}
}
if (removedCount > 0) {
await setCachedTools(tools, userId ? { userId } : { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(
`[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`,
);
}
} catch (error) {
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
throw error;
}
}
module.exports = {
mergeAppTools,
mergeUserTools,
updateMCPUserTools,
clearMCPServerTools,
};

View File

@@ -1,6 +1,7 @@
const { Providers } = require('@librechat/agents');
const {
primeResources,
getModelMaxTokens,
extractLibreChatParams,
optionalChainWithEmptyCheck,
} = require('@librechat/api');
@@ -17,7 +18,6 @@ const { getProviderConfig } = require('~/server/services/Endpoints');
const { processFiles } = require('~/server/services/Files/process');
const { getFiles, getToolFilesByIds } = require('~/models/File');
const { getConvoFiles } = require('~/models/Conversation');
const { getModelMaxTokens } = require('~/utils');
/**
* @param {object} params

View File

@@ -1,6 +1,6 @@
const { getLLMConfig } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
const AnthropicClient = require('~/app/clients/AnthropicClient');
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
@@ -40,7 +40,6 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
clientOptions = Object.assign(
{
proxy: PROXY ?? null,
userId: req.user.id,
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
modelOptions: endpointOption?.model_parameters ?? {},
},
@@ -49,6 +48,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (overrideModel) {
clientOptions.modelOptions.model = overrideModel;
}
clientOptions.modelOptions.user = req.user.id;
return getLLMConfig(anthropicApiKey, clientOptions);
}

View File

@@ -1,103 +0,0 @@
const { ProxyAgent } = require('undici');
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
/**
* Generates configuration options for creating an Anthropic language model (LLM) instance.
*
* @param {string} apiKey - The API key for authentication with Anthropic.
* @param {Object} [options={}] - Additional options for configuring the LLM.
* @param {Object} [options.modelOptions] - Model-specific options.
* @param {string} [options.modelOptions.model] - The name of the model to use.
* @param {number} [options.modelOptions.maxOutputTokens] - The maximum number of tokens to generate.
* @param {number} [options.modelOptions.temperature] - Controls randomness in output generation.
* @param {number} [options.modelOptions.topP] - Controls diversity of output generation.
* @param {number} [options.modelOptions.topK] - Controls the number of top tokens to consider.
* @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens.
* @param {boolean} [options.modelOptions.stream] - Whether to stream the response.
* @param {string} options.userId - The user ID for tracking and personalization.
* @param {string} [options.proxy] - Proxy server URL.
* @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used.
*
* @returns {Object} Configuration options for creating an Anthropic LLM instance, with null and undefined values removed.
*/
function getLLMConfig(apiKey, options = {}) {
const systemOptions = {
thinking: options.modelOptions.thinking ?? anthropicSettings.thinking.default,
promptCache: options.modelOptions.promptCache ?? anthropicSettings.promptCache.default,
thinkingBudget: options.modelOptions.thinkingBudget ?? anthropicSettings.thinkingBudget.default,
};
for (let key in systemOptions) {
delete options.modelOptions[key];
}
const defaultOptions = {
model: anthropicSettings.model.default,
maxOutputTokens: anthropicSettings.maxOutputTokens.default,
stream: true,
};
const mergedOptions = Object.assign(defaultOptions, options.modelOptions);
/** @type {AnthropicClientOptions} */
let requestOptions = {
apiKey,
model: mergedOptions.model,
stream: mergedOptions.stream,
temperature: mergedOptions.temperature,
stopSequences: mergedOptions.stop,
maxTokens:
mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model),
clientOptions: {},
invocationKwargs: {
metadata: {
user_id: options.userId,
},
},
};
requestOptions = configureReasoning(requestOptions, systemOptions);
if (!/claude-3[-.]7/.test(mergedOptions.model)) {
requestOptions.topP = mergedOptions.topP;
requestOptions.topK = mergedOptions.topK;
} else if (requestOptions.thinking == null) {
requestOptions.topP = mergedOptions.topP;
requestOptions.topK = mergedOptions.topK;
}
const supportsCacheControl =
systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model);
const headers = getClaudeHeaders(requestOptions.model, supportsCacheControl);
if (headers) {
requestOptions.clientOptions.defaultHeaders = headers;
}
if (options.proxy) {
const proxyAgent = new ProxyAgent(options.proxy);
requestOptions.clientOptions.fetchOptions = {
dispatcher: proxyAgent,
};
}
if (options.reverseProxyUrl) {
requestOptions.clientOptions.baseURL = options.reverseProxyUrl;
requestOptions.anthropicApiUrl = options.reverseProxyUrl;
}
const tools = [];
if (mergedOptions.web_search) {
tools.push({
type: 'web_search_20250305',
name: 'web_search',
});
}
return {
tools,
/** @type {AnthropicClientOptions} */
llmConfig: removeNullishValues(requestOptions),
};
}
module.exports = { getLLMConfig };

View File

@@ -1,341 +0,0 @@
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
jest.mock('https-proxy-agent', () => ({
HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })),
}));
describe('getLLMConfig', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create a basic configuration with default values', () => {
const result = getLLMConfig('test-api-key', { modelOptions: {} });
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
expect(result.llmConfig).toHaveProperty('model', 'claude-3-5-sonnet-latest');
expect(result.llmConfig).toHaveProperty('stream', true);
expect(result.llmConfig).toHaveProperty('maxTokens');
});
it('should include proxy settings when provided', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {},
proxy: 'http://proxy:8080',
});
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
'ProxyAgent',
);
});
it('should include reverse proxy URL when provided', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {},
reverseProxyUrl: 'http://reverse-proxy',
});
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'http://reverse-proxy');
expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'http://reverse-proxy');
});
it('should include topK and topP for non-Claude-3.7 models', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-opus',
topK: 10,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('topK', 10);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
it('should include topK and topP for Claude-3.5 models', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-5-sonnet',
topK: 10,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('topK', 10);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
it('should NOT include topK and topP for Claude-3-7 models with thinking enabled (hyphen notation)', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-7-sonnet',
topK: 10,
topP: 0.9,
thinking: true,
},
});
expect(result.llmConfig).not.toHaveProperty('topK');
expect(result.llmConfig).not.toHaveProperty('topP');
expect(result.llmConfig).toHaveProperty('thinking');
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
// When thinking is enabled, it uses the default thinkingBudget of 2000
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
});
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model', () => {
const modelOptions = {
model: 'claude-sonnet-4-20250514',
promptCache: true,
};
const result = getLLMConfig('test-key', { modelOptions });
const clientOptions = result.llmConfig.clientOptions;
expect(clientOptions.defaultHeaders).toBeDefined();
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
'prompt-caching-2024-07-31,context-1m-2025-08-07',
);
});
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model formats', () => {
const modelVariations = [
'claude-sonnet-4-20250514',
'claude-sonnet-4-latest',
'anthropic/claude-sonnet-4-20250514',
];
modelVariations.forEach((model) => {
const modelOptions = { model, promptCache: true };
const result = getLLMConfig('test-key', { modelOptions });
const clientOptions = result.llmConfig.clientOptions;
expect(clientOptions.defaultHeaders).toBeDefined();
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
'prompt-caching-2024-07-31,context-1m-2025-08-07',
);
});
});
it('should NOT include topK and topP for Claude-3.7 models with thinking enabled (decimal notation)', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3.7-sonnet',
topK: 10,
topP: 0.9,
thinking: true,
},
});
expect(result.llmConfig).not.toHaveProperty('topK');
expect(result.llmConfig).not.toHaveProperty('topP');
expect(result.llmConfig).toHaveProperty('thinking');
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
// When thinking is enabled, it uses the default thinkingBudget of 2000
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
});
it('should handle custom maxOutputTokens', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-opus',
maxOutputTokens: 2048,
},
});
expect(result.llmConfig).toHaveProperty('maxTokens', 2048);
});
it('should handle promptCache setting', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-5-sonnet',
promptCache: true,
},
});
// We're not checking specific header values since that depends on the actual helper function
// Just verifying that the promptCache setting is processed
expect(result.llmConfig).toBeDefined();
});
it('should include topK and topP for Claude-3.7 models when thinking is not enabled', () => {
// Test with thinking explicitly set to null/undefined
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-7-sonnet',
topK: 10,
topP: 0.9,
thinking: false,
},
});
expect(result.llmConfig).toHaveProperty('topK', 10);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
// Test with thinking explicitly set to false
const result2 = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-7-sonnet',
topK: 10,
topP: 0.9,
thinking: false,
},
});
expect(result2.llmConfig).toHaveProperty('topK', 10);
expect(result2.llmConfig).toHaveProperty('topP', 0.9);
// Test with decimal notation as well
const result3 = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3.7-sonnet',
topK: 10,
topP: 0.9,
thinking: false,
},
});
expect(result3.llmConfig).toHaveProperty('topK', 10);
expect(result3.llmConfig).toHaveProperty('topP', 0.9);
});
describe('Edge cases', () => {
it('should handle missing apiKey', () => {
const result = getLLMConfig(undefined, { modelOptions: {} });
expect(result.llmConfig).not.toHaveProperty('apiKey');
});
it('should handle empty modelOptions', () => {
expect(() => {
getLLMConfig('test-api-key', {});
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
});
it('should handle no options parameter', () => {
expect(() => {
getLLMConfig('test-api-key');
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
});
it('should handle temperature, stop sequences, and stream settings', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
temperature: 0.7,
stop: ['\n\n', 'END'],
stream: false,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
expect(result.llmConfig).toHaveProperty('stopSequences', ['\n\n', 'END']);
expect(result.llmConfig).toHaveProperty('stream', false);
});
it('should handle maxOutputTokens when explicitly set to falsy value', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-opus',
maxOutputTokens: null,
},
});
// The actual anthropicSettings.maxOutputTokens.reset('claude-3-opus') returns 4096
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
});
it('should handle both proxy and reverseProxyUrl', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {},
proxy: 'http://proxy:8080',
reverseProxyUrl: 'https://reverse-proxy.com',
});
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
'ProxyAgent',
);
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'https://reverse-proxy.com');
expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'https://reverse-proxy.com');
});
it('should handle prompt cache with supported model', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-5-sonnet',
promptCache: true,
},
});
// claude-3-5-sonnet supports prompt caching and should get the appropriate headers
expect(result.llmConfig.clientOptions.defaultHeaders).toEqual({
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
});
});
it('should handle thinking and thinkingBudget options', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-7-sonnet',
thinking: true,
thinkingBudget: 10000, // This exceeds the default max_tokens of 8192
},
});
// The function should add thinking configuration for claude-3-7 models
expect(result.llmConfig).toHaveProperty('thinking');
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
// With claude-3-7-sonnet, the max_tokens default is 8192
// Budget tokens gets adjusted to 90% of max_tokens (8192 * 0.9 = 7372) when it exceeds max_tokens
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 7372);
// Test with budget_tokens within max_tokens limit
const result2 = getLLMConfig('test-api-key', {
modelOptions: {
model: 'claude-3-7-sonnet',
thinking: true,
thinkingBudget: 2000,
},
});
expect(result2.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
});
it('should remove system options from modelOptions', () => {
const modelOptions = {
model: 'claude-3-opus',
thinking: true,
promptCache: true,
thinkingBudget: 1000,
temperature: 0.5,
};
getLLMConfig('test-api-key', { modelOptions });
expect(modelOptions).not.toHaveProperty('thinking');
expect(modelOptions).not.toHaveProperty('promptCache');
expect(modelOptions).not.toHaveProperty('thinkingBudget');
expect(modelOptions).toHaveProperty('temperature', 0.5);
});
it('should handle all nullish values removal', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
temperature: null,
topP: undefined,
topK: 0,
stop: [],
},
});
expect(result.llmConfig).not.toHaveProperty('temperature');
expect(result.llmConfig).not.toHaveProperty('topP');
expect(result.llmConfig).toHaveProperty('topK', 0);
expect(result.llmConfig).toHaveProperty('stopSequences', []);
});
});
});

View File

@@ -1,3 +1,4 @@
const { getModelMaxTokens } = require('@librechat/api');
const { createContentAggregator } = require('@librechat/agents');
const {
EModelEndpoint,
@@ -7,7 +8,6 @@ const {
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
const getOptions = require('~/server/services/Endpoints/bedrock/options');
const AgentClient = require('~/server/controllers/agents/client');
const { getModelMaxTokens } = require('~/utils');
const initializeClient = async ({ req, res, endpointOption }) => {
if (!endpointOption) {

View File

@@ -36,10 +36,12 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
/** Intentionally excludes passing `body`, i.e. `req.body`, as
* values may not be accurate until `AgentClient` is initialized
*/
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
body: req.body,
});
if (CUSTOM_API_KEY.match(envVarRegex)) {

View File

@@ -76,7 +76,10 @@ describe('custom/initializeClient', () => {
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
/**
* Note: Request-based Header Resolution is deferred until right before LLM request is made
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
*/
});
});

View File

@@ -159,9 +159,11 @@ class STTService {
* Prepares the request for the OpenAI STT provider.
* @param {Object} sttSchema - The STT schema for OpenAI.
* @param {Stream} audioReadStream - The audio data to be transcribed.
* @param {Object} audioFile - The audio file object (unused in OpenAI provider).
* @param {string} language - The language code for the transcription.
* @returns {Array} An array containing the URL, data, and headers for the request.
*/
openAIProvider(sttSchema, audioReadStream) {
openAIProvider(sttSchema, audioReadStream, audioFile, language) {
const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions';
const apiKey = extractEnvVariable(sttSchema.apiKey) || '';
@@ -170,6 +172,12 @@ class STTService {
model: sttSchema.model,
};
if (language) {
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
const isoLanguage = language.split('-')[0];
data.language = isoLanguage;
}
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
@@ -184,10 +192,11 @@ class STTService {
* @param {Object} sttSchema - The STT schema for Azure OpenAI.
* @param {Buffer} audioBuffer - The audio data to be transcribed.
* @param {Object} audioFile - The audio file object containing originalname, mimetype, and size.
* @param {string} language - The language code for the transcription.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the audio file size exceeds 25MB or the audio file format is not accepted.
*/
azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
azureOpenAIProvider(sttSchema, audioBuffer, audioFile, language) {
const url = `${genAzureEndpoint({
azureOpenAIApiInstanceName: extractEnvVariable(sttSchema?.instanceName),
azureOpenAIApiDeploymentName: extractEnvVariable(sttSchema?.deploymentName),
@@ -211,6 +220,12 @@ class STTService {
contentType: audioFile.mimetype,
});
if (language) {
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
const isoLanguage = language.split('-')[0];
formData.append('language', isoLanguage);
}
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { 'api-key': apiKey }),
@@ -229,10 +244,11 @@ class STTService {
* @param {Object} requestData - The data required for the STT request.
* @param {Buffer} requestData.audioBuffer - The audio data to be transcribed.
* @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size.
* @param {string} requestData.language - The language code for the transcription.
* @returns {Promise<string>} A promise that resolves to the transcribed text.
* @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing.
*/
async sttRequest(provider, sttSchema, { audioBuffer, audioFile }) {
async sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }) {
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error('Invalid provider');
@@ -243,7 +259,13 @@ class STTService {
const audioReadStream = Readable.from(audioBuffer);
audioReadStream.path = `audio.${fileExtension}`;
const [url, data, headers] = strategy.call(this, sttSchema, audioReadStream, audioFile);
const [url, data, headers] = strategy.call(
this,
sttSchema,
audioReadStream,
audioFile,
language,
);
try {
const response = await axios.post(url, data, { headers });
@@ -284,7 +306,8 @@ class STTService {
try {
const [provider, sttSchema] = await this.getProviderSchema(req);
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
const language = req.body?.language || '';
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
res.json({ text });
} catch (error) {
logger.error('An error occurred while processing the audio:', error);

View File

@@ -14,16 +14,18 @@ const { getProvider } = require('./TTSService');
*/
async function getVoices(req, res) {
try {
const appConfig = await getAppConfig({
role: req.user?.role,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
}));
if (!appConfig || !appConfig?.speech?.tts) {
const ttsSchema = appConfig?.speech?.tts;
if (!ttsSchema) {
throw new Error('Configuration or TTS schema is missing');
}
const ttsSchema = appConfig?.speech?.tts;
const provider = await getProvider(ttsSchema);
const provider = await getProvider(appConfig);
let voices;
switch (provider) {

View File

@@ -17,7 +17,7 @@ const { Files } = require('~/models');
* @param {IUser} options.user - The user object
* @param {AppConfig} options.appConfig - The app configuration object
* @param {GraphRunnableConfig['configurable']} options.metadata - The metadata
* @param {any} options.toolArtifact - The tool artifact containing structured data
* @param {{ [Tools.file_search]: { sources: Object[]; fileCitations: boolean } }} options.toolArtifact - The tool artifact containing structured data
* @param {string} options.toolCallId - The tool call ID
* @returns {Promise<Object|null>} The file search attachment or null
*/
@@ -29,12 +29,14 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
if (user) {
try {
const hasFileCitationsAccess = await checkAccess({
user,
permissionType: PermissionTypes.FILE_CITATIONS,
permissions: [Permissions.USE],
getRoleByName,
});
const hasFileCitationsAccess =
toolArtifact?.[Tools.file_search]?.fileCitations ??
(await checkAccess({
user,
permissionType: PermissionTypes.FILE_CITATIONS,
permissions: [Permissions.USE],
getRoleByName,
}));
if (!hasFileCitationsAccess) {
logger.debug(

View File

@@ -10,9 +10,10 @@ const { getAgent } = require('~/models/Agent');
* @param {string} [params.role] - Optional user role to avoid DB query
* @param {string[]} params.fileIds - Array of file IDs to check
* @param {string} params.agentId - The agent ID that might grant access
* @param {boolean} [params.isDelete] - Whether the operation is a delete operation
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
*/
const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId }) => {
const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId, isDelete }) => {
const accessMap = new Map();
// Initialize all files as no access
@@ -44,22 +45,23 @@ const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId }) => {
return accessMap;
}
// Check if user has EDIT permission (which would indicate collaborative access)
const hasEditPermission = await checkPermission({
userId,
role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.EDIT,
});
if (isDelete) {
// Check if user has EDIT permission (which would indicate collaborative access)
const hasEditPermission = await checkPermission({
userId,
role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.EDIT,
});
// If user only has VIEW permission, they can't access files
// Only users with EDIT permission or higher can access agent files
if (!hasEditPermission) {
return accessMap;
// If user only has VIEW permission, they can't access files
// Only users with EDIT permission or higher can access agent files
if (!hasEditPermission) {
return accessMap;
}
}
// User has edit permissions - check which files are actually attached
const attachedFileIds = new Set();
if (agent.tool_resources) {
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {

View File

@@ -552,7 +552,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
throw new Error('File search is not enabled for Agents');
}
// Note: File search processing continues to dual storage logic below
} else if (tool_resource === EToolResources.ocr) {
} else if (tool_resource === EToolResources.context) {
const { file_id, temp_file_id = null } = metadata;
/**
@@ -616,7 +616,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
if (shouldUseSTT) {
const sttService = await STTService.getInstance();
const { text, bytes } = await processAudioFile({ file, sttService });
const { text, bytes } = await processAudioFile({ req, file, sttService });
return await createTextFile({ text, bytes });
}
@@ -646,8 +646,8 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
req,
file,
file_id,
entity_id,
basePath,
entity_id,
});
// SECOND: Upload to Vector DB
@@ -670,17 +670,18 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
req,
file,
file_id,
entity_id,
basePath,
entity_id,
});
}
const { bytes, filename, filepath: _filepath, height, width } = storageResult;
let { bytes, filename, filepath: _filepath, height, width } = storageResult;
// For RAG files, use embedding result; for others, use storage result
const embedded =
tool_resource === EToolResources.file_search
? embeddingResult?.embedded
: storageResult.embedded;
let embedded = storageResult.embedded;
if (tool_resource === EToolResources.file_search) {
embedded = embeddingResult?.embedded;
filename = embeddingResult?.filename || filename;
}
let filepath = _filepath;
@@ -929,6 +930,7 @@ async function saveBase64Image(
url,
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
) {
const appConfig = req.config;
const effectiveResolution = resolution ?? appConfig.fileConfig?.imageGeneration ?? 'high';
const file_id = _file_id ?? v4();
let filename = `${file_id}-${_filename}`;
@@ -943,7 +945,6 @@ async function saveBase64Image(
}
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint);
const appConfig = req.config;
const source = getFileStrategy(appConfig, { isImage: true });
const { saveBuffer } = getStrategyFunctions(source);
const filepath = await saveBuffer({

View File

@@ -20,10 +20,10 @@ const {
ContentTypes,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, getAppConfig } = require('./Config');
const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache');
/**
@@ -152,8 +152,8 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
/**
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.userId - The user ID from the request object.
* @param {string} params.serverName
* @param {AbortSignal} params.signal
* @param {string} params.model
@@ -161,9 +161,9 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap }) {
async function reconnectServer({ res, userId, index, signal, serverName, userMCPAuthMap }) {
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${req.user?.id}:${serverName}:${Date.now()}`;
const flowId = `${userId}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const stepId = 'step_oauth_login_' + serverName;
const toolCall = {
@@ -192,7 +192,7 @@ async function reconnectServer({ req, res, index, signal, serverName, userMCPAut
flowManager,
});
return await reinitMCPServer({
req,
userId,
signal,
serverName,
oauthStart,
@@ -211,8 +211,8 @@ async function reconnectServer({ req, res, index, signal, serverName, userMCPAut
* i.e. `availableTools`, and will reinitialize the MCP server to ensure all tools are generated.
*
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.userId - The user ID from the request object.
* @param {string} params.serverName
* @param {string} params.model
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
@@ -221,8 +221,16 @@ async function reconnectServer({ req, res, index, signal, serverName, userMCPAut
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function createMCPTools({ req, res, index, signal, serverName, provider, userMCPAuthMap }) {
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
async function createMCPTools({
res,
userId,
index,
signal,
serverName,
provider,
userMCPAuthMap,
}) {
const result = await reconnectServer({ res, userId, index, signal, serverName, userMCPAuthMap });
if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
return;
@@ -231,8 +239,8 @@ async function createMCPTools({ req, res, index, signal, serverName, provider, u
const serverTools = [];
for (const tool of result.tools) {
const toolInstance = await createMCPTool({
req,
res,
userId,
provider,
userMCPAuthMap,
availableTools: result.availableTools,
@@ -249,8 +257,8 @@ async function createMCPTools({ req, res, index, signal, serverName, provider, u
/**
* Creates a single tool from the specified MCP Server via `toolKey`.
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.userId - The user ID from the request object.
* @param {string} params.toolKey - The toolKey for the tool.
* @param {string} params.model - The model for the tool.
* @param {number} [params.index]
@@ -261,25 +269,31 @@ async function createMCPTools({ req, res, index, signal, serverName, provider, u
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({
req,
res,
userId,
index,
signal,
toolKey,
provider,
userMCPAuthMap,
availableTools: tools,
availableTools,
}) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const availableTools =
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
/** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
);
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
const result = await reconnectServer({
res,
userId,
index,
signal,
serverName,
userMCPAuthMap,
});
toolDefinition = result?.availableTools?.[toolKey]?.function;
}
@@ -537,13 +551,20 @@ async function getServerConnectionStatus(
const baseConnectionState = getConnectionState();
let finalConnectionState = baseConnectionState;
// connection state overrides specific to OAuth servers
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
// check if server is actively being reconnected
const oauthReconnectionManager = getOAuthReconnectionManager();
if (oauthReconnectionManager.isReconnecting(userId, serverName)) {
finalConnectionState = 'connecting';
} else {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
finalConnectionState = 'connecting';
}
}
}

View File

@@ -31,6 +31,7 @@ jest.mock('./Config', () => ({
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getOAuthReconnectionManager: jest.fn(),
}));
jest.mock('~/cache', () => ({
@@ -48,6 +49,7 @@ describe('tests for the new helper functions used by the MCP connection status e
let mockGetMCPManager;
let mockGetFlowStateManager;
let mockGetLogStores;
let mockGetOAuthReconnectionManager;
beforeEach(() => {
jest.clearAllMocks();
@@ -56,6 +58,7 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetMCPManager = require('~/config').getMCPManager;
mockGetFlowStateManager = require('~/config').getFlowStateManager;
mockGetLogStores = require('~/cache').getLogStores;
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
});
describe('getMCPSetupData', () => {
@@ -354,6 +357,12 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
@@ -370,6 +379,12 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return failed flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
@@ -401,6 +416,12 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return active flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
@@ -432,6 +453,12 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return no flow
const mockFlowManager = {
getFlowState: jest.fn(() => null),
@@ -454,6 +481,35 @@ describe('tests for the new helper functions used by the MCP connection status e
});
});
it('should return connecting state when OAuth server is reconnecting', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager to return true for isReconnecting
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => true),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
});
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
mockUserId,
mockServerName,
);
});
it('should not check OAuth flow status when server is connected', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),

View File

@@ -1,13 +1,13 @@
const axios = require('axios');
const { Providers } = require('@librechat/agents');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
const { inputSchema, extractBaseURL, processModelData } = require('~/utils');
const { OllamaClient } = require('~/app/clients/OllamaClient');
const { isUserProvided } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
const { extractBaseURL } = require('~/utils');
/**
* Splits a string by commas and trims each resulting value.

View File

@@ -11,8 +11,8 @@ const {
getAnthropicModels,
} = require('./ModelService');
jest.mock('~/utils', () => {
const originalUtils = jest.requireActual('~/utils');
jest.mock('@librechat/api', () => {
const originalUtils = jest.requireActual('@librechat/api');
return {
...originalUtils,
processModelData: jest.fn((...args) => {
@@ -108,7 +108,7 @@ describe('fetchModels with createTokenConfig true', () => {
beforeEach(() => {
// Clears the mock's history before each test
const _utils = require('~/utils');
const _utils = require('@librechat/api');
axios.get.mockResolvedValue({ data });
});
@@ -120,7 +120,7 @@ describe('fetchModels with createTokenConfig true', () => {
createTokenConfig: true,
});
const { processModelData } = require('~/utils');
const { processModelData } = require('@librechat/api');
expect(processModelData).toHaveBeenCalled();
expect(processModelData).toHaveBeenCalledWith(data);
});

View File

@@ -313,7 +313,7 @@ const ensurePrincipalExists = async function (principal) {
idOnTheSource: principal.idOnTheSource,
};
const userId = await createUser(userData, true, false);
const userId = await createUser(userData, true, true);
return userId.toString();
}

View File

@@ -74,7 +74,7 @@ async function processRequiredActions(client, requiredActions) {
requiredActions,
);
const appConfig = client.req.config;
const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true });
const toolDefinitions = await getCachedTools();
const seenToolkits = new Set();
const tools = requiredActions
.map((action) => {
@@ -353,7 +353,12 @@ async function processRequiredActions(client, requiredActions) {
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
if (!agent.tools || agent.tools.length === 0) {
return {};
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) {
} else if (
agent.tools &&
agent.tools.length === 1 &&
/** Legacy handling for `ocr` as may still exist in existing Agents */
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return {};
}

View File

@@ -2,12 +2,12 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { updateMCPUserTools } = require('~/server/services/Config');
const { updateMCPServerTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
/**
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.userId
* @param {string} params.serverName - The name of the MCP server
* @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish
* @param {AbortSignal} [params.signal] - The abort signal to handle cancellation.
@@ -18,7 +18,7 @@ const { getLogStores } = require('~/cache');
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
*/
async function reinitMCPServer({
req,
userId,
signal,
forceNew,
serverName,
@@ -44,14 +44,14 @@ async function reinitMCPServer({
const oauthStart =
_oauthStart ??
(async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
logger.info(`[MCP Reinitialize] OAuth URL received for ${serverName}`);
oauthUrl = authURL;
oauthRequired = true;
});
try {
userConnection = await mcpManager.getUserConnection({
user: req.user,
user: { id: userId },
signal,
forceNew,
oauthStart,
@@ -97,8 +97,7 @@ async function reinitMCPServer({
if (userConnection && !oauthRequired) {
tools = await userConnection.fetchTools();
availableTools = await updateMCPUserTools({
userId: req.user.id,
availableTools = await updateMCPServerTools({
serverName,
tools,
});

View File

@@ -0,0 +1,26 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { createOAuthReconnectionManager, getFlowStateManager } = require('~/config');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { getLogStores } = require('~/cache');
/**
* Initialize OAuth reconnect manager
*/
async function initializeOAuthReconnectManager() {
try {
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const tokenMethods = {
findToken,
updateToken,
createToken,
deleteTokens,
};
await createOAuthReconnectionManager(flowManager, tokenMethods);
logger.info(`OAuth reconnect manager initialized successfully.`);
} catch (error) {
logger.error('Failed to initialize OAuth reconnect manager:', error);
}
}
module.exports = initializeOAuthReconnectManager;

View File

@@ -229,7 +229,7 @@
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href=&quot;{{verificationLink}}&quot;" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{verificationLink}}" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{verificationLink}}'
target='_blank'

View File

@@ -10,6 +10,10 @@ jest.mock('~/models/Message', () => ({
bulkSaveMessages: jest.fn(),
}));
jest.mock('~/models/ConversationTag', () => ({
bulkIncrementTagCounts: jest.fn(),
}));
let mockIdCounter = 0;
jest.mock('uuid', () => {
return {
@@ -22,11 +26,13 @@ jest.mock('uuid', () => {
const {
forkConversation,
duplicateConversation,
splitAtTargetLevel,
getAllMessagesUpToParent,
getMessagesUpToTargetLevel,
cloneMessagesWithTimestamps,
} = require('./fork');
const { bulkIncrementTagCounts } = require('~/models/ConversationTag');
const { getConvo, bulkSaveConvos } = require('~/models/Conversation');
const { getMessages, bulkSaveMessages } = require('~/models/Message');
const { createImportBatchBuilder } = require('./importBatchBuilder');
@@ -181,6 +187,120 @@ describe('forkConversation', () => {
}),
).rejects.toThrow('Failed to fetch messages');
});
test('should increment tag counts when forking conversation with tags', async () => {
const mockConvoWithTags = {
...mockConversation,
tags: ['bookmark1', 'bookmark2'],
};
getConvo.mockResolvedValue(mockConvoWithTags);
await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
// Verify that bulkIncrementTagCounts was called with correct tags
expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', ['bookmark1', 'bookmark2']);
});
test('should handle conversation without tags when forking', async () => {
const mockConvoWithoutTags = {
...mockConversation,
// No tags field
};
getConvo.mockResolvedValue(mockConvoWithoutTags);
await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
// bulkIncrementTagCounts will be called with array containing undefined
expect(bulkIncrementTagCounts).toHaveBeenCalled();
});
test('should handle empty tags array when forking', async () => {
const mockConvoWithEmptyTags = {
...mockConversation,
tags: [],
};
getConvo.mockResolvedValue(mockConvoWithEmptyTags);
await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
// bulkIncrementTagCounts will be called with empty array
expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', []);
});
});
describe('duplicateConversation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIdCounter = 0;
getConvo.mockResolvedValue(mockConversation);
getMessages.mockResolvedValue(mockMessages);
bulkSaveConvos.mockResolvedValue(null);
bulkSaveMessages.mockResolvedValue(null);
bulkIncrementTagCounts.mockResolvedValue(null);
});
test('should duplicate conversation and increment tag counts', async () => {
const mockConvoWithTags = {
...mockConversation,
tags: ['important', 'work', 'project'],
};
getConvo.mockResolvedValue(mockConvoWithTags);
await duplicateConversation({
userId: 'user1',
conversationId: 'abc123',
});
// Verify that bulkIncrementTagCounts was called with correct tags
expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', ['important', 'work', 'project']);
});
test('should duplicate conversation without tags', async () => {
const mockConvoWithoutTags = {
...mockConversation,
// No tags field
};
getConvo.mockResolvedValue(mockConvoWithoutTags);
await duplicateConversation({
userId: 'user1',
conversationId: 'abc123',
});
// bulkIncrementTagCounts will be called with array containing undefined
expect(bulkIncrementTagCounts).toHaveBeenCalled();
});
test('should handle empty tags array when duplicating', async () => {
const mockConvoWithEmptyTags = {
...mockConversation,
tags: [],
};
getConvo.mockResolvedValue(mockConvoWithEmptyTags);
await duplicateConversation({
userId: 'user1',
conversationId: 'abc123',
});
// bulkIncrementTagCounts will be called with empty array
expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', []);
});
});
const mockMessagesComplex = [

View File

@@ -1,5 +1,6 @@
const { v4: uuidv4 } = require('uuid');
const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
const { bulkIncrementTagCounts } = require('~/models/ConversationTag');
const { bulkSaveConvos } = require('~/models/Conversation');
const { bulkSaveMessages } = require('~/models/Message');
const { logger } = require('~/config');
@@ -93,13 +94,22 @@ class ImportBatchBuilder {
/**
* Saves the batch of conversations and messages to the DB.
* Also increments tag counts for any existing tags.
* @returns {Promise<void>} A promise that resolves when the batch is saved.
* @throws {Error} If there is an error saving the batch.
*/
async saveBatch() {
try {
await bulkSaveConvos(this.conversations);
await bulkSaveMessages(this.messages, true);
const promises = [];
promises.push(bulkSaveConvos(this.conversations));
promises.push(bulkSaveMessages(this.messages, true));
promises.push(
bulkIncrementTagCounts(
this.requestUserId,
this.conversations.flatMap((convo) => convo.tags),
),
);
await Promise.all(promises);
logger.debug(
`user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`,
);

View File

@@ -1,6 +1,6 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { getImporter } = require('./importers');
const { logger } = require('~/config');
/**
* Job definition for importing a conversation.

View File

@@ -1,9 +1,9 @@
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, openAISettings, CacheKeys } = require('librechat-data-provider');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { cloneMessagesWithTimestamps } = require('./fork');
const getLogStores = require('~/cache/getLogStores');
const logger = require('~/config/winston');
/**
* Returns the appropriate importer function based on the provided JSON data.
@@ -212,6 +212,29 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
}
}
/**
* Helper function to find the nearest non-system parent
* @param {string} parentId - The ID of the parent message.
* @returns {string} The ID of the nearest non-system parent message.
*/
const findNonSystemParent = (parentId) => {
if (!parentId || !messageMap.has(parentId)) {
return Constants.NO_PARENT;
}
const parentMapping = conv.mapping[parentId];
if (!parentMapping?.message) {
return Constants.NO_PARENT;
}
/* If parent is a system message, traverse up to find the nearest non-system parent */
if (parentMapping.message.author?.role === 'system') {
return findNonSystemParent(parentMapping.parent);
}
return messageMap.get(parentId);
};
// Create and save messages using the mapped IDs
const messages = [];
for (const [id, mapping] of Object.entries(conv.mapping)) {
@@ -220,23 +243,27 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
messageMap.delete(id);
continue;
} else if (role === 'system') {
messageMap.delete(id);
// Skip system messages but keep their ID in messageMap for parent references
continue;
}
const newMessageId = messageMap.get(id);
const parentMessageId =
mapping.parent && messageMap.has(mapping.parent)
? messageMap.get(mapping.parent)
: Constants.NO_PARENT;
const parentMessageId = findNonSystemParent(mapping.parent);
const messageText = formatMessageText(mapping.message);
const isCreatedByUser = role === 'user';
let sender = isCreatedByUser ? 'user' : 'GPT-3.5';
let sender = isCreatedByUser ? 'user' : 'assistant';
const model = mapping.message.metadata.model_slug || openAISettings.model.default;
if (model.includes('gpt-4')) {
sender = 'GPT-4';
if (!isCreatedByUser) {
/** Extracted model name from model slug */
const gptMatch = model.match(/gpt-(.+)/i);
if (gptMatch) {
sender = `GPT-${gptMatch[1]}`;
} else {
sender = model || 'assistant';
}
}
messages.push({

View File

@@ -99,6 +99,404 @@ describe('importChatGptConvo', () => {
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
});
it('should handle system messages without breaking parent-child relationships', async () => {
/**
* Test data that reproduces message graph "breaking" when it encounters a system message
*/
const testData = [
{
title: 'System Message Parent Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['user-msg-1'],
},
'user-msg-1': {
id: 'user-msg-1',
message: {
id: 'user-msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['First user message'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'root-node',
children: ['assistant-msg-1'],
},
'assistant-msg-1': {
id: 'assistant-msg-1',
message: {
id: 'assistant-msg-1',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['First assistant response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-1',
children: ['system-msg'],
},
'system-msg': {
id: 'system-msg',
message: {
id: 'system-msg',
author: { role: 'system' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['System message in middle'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'assistant-msg-1',
children: ['user-msg-2'],
},
'user-msg-2': {
id: 'user-msg-2',
message: {
id: 'user-msg-2',
author: { role: 'user' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['Second user message'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'system-msg',
children: ['assistant-msg-2'],
},
'assistant-msg-2': {
id: 'assistant-msg-2',
message: {
id: 'assistant-msg-2',
author: { role: 'assistant' },
create_time: 1714585035.150442,
content: { content_type: 'text', parts: ['Second assistant response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-2',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
/** 2 user messages + 2 assistant messages (system message should be skipped) */
const expectedMessages = 4;
expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedMessages);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
const messageMap = new Map();
savedMessages.forEach((msg) => {
messageMap.set(msg.text, msg);
});
const firstUser = messageMap.get('First user message');
const firstAssistant = messageMap.get('First assistant response');
const secondUser = messageMap.get('Second user message');
const secondAssistant = messageMap.get('Second assistant response');
expect(firstUser).toBeDefined();
expect(firstAssistant).toBeDefined();
expect(secondUser).toBeDefined();
expect(secondAssistant).toBeDefined();
expect(firstUser.parentMessageId).toBe(Constants.NO_PARENT);
expect(firstAssistant.parentMessageId).toBe(firstUser.messageId);
// This is the key test: second user message should have first assistant as parent
// (not NO_PARENT which would indicate the system message broke the chain)
expect(secondUser.parentMessageId).toBe(firstAssistant.messageId);
expect(secondAssistant.parentMessageId).toBe(secondUser.messageId);
});
it('should maintain correct sender for user messages regardless of GPT-4 model', async () => {
/**
* Test data with GPT-4 model to ensure user messages keep 'user' sender
*/
const testData = [
{
title: 'GPT-4 Sender Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['user-msg-1'],
},
'user-msg-1': {
id: 'user-msg-1',
message: {
id: 'user-msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['User message with GPT-4'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'root-node',
children: ['assistant-msg-1'],
},
'assistant-msg-1': {
id: 'assistant-msg-1',
message: {
id: 'assistant-msg-1',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['Assistant response with GPT-4'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-1',
children: ['user-msg-2'],
},
'user-msg-2': {
id: 'user-msg-2',
message: {
id: 'user-msg-2',
author: { role: 'user' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['Another user message with GPT-4o-mini'] },
metadata: { model_slug: 'gpt-4o-mini' },
},
parent: 'assistant-msg-1',
children: ['assistant-msg-2'],
},
'assistant-msg-2': {
id: 'assistant-msg-2',
message: {
id: 'assistant-msg-2',
author: { role: 'assistant' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['Assistant response with GPT-3.5'] },
metadata: { model_slug: 'gpt-3.5-turbo' },
},
parent: 'user-msg-2',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
const userMsg1 = savedMessages.find((msg) => msg.text === 'User message with GPT-4');
const assistantMsg1 = savedMessages.find((msg) => msg.text === 'Assistant response with GPT-4');
const userMsg2 = savedMessages.find(
(msg) => msg.text === 'Another user message with GPT-4o-mini',
);
const assistantMsg2 = savedMessages.find(
(msg) => msg.text === 'Assistant response with GPT-3.5',
);
expect(userMsg1.sender).toBe('user');
expect(userMsg1.isCreatedByUser).toBe(true);
expect(userMsg1.model).toBe('gpt-4');
expect(userMsg2.sender).toBe('user');
expect(userMsg2.isCreatedByUser).toBe(true);
expect(userMsg2.model).toBe('gpt-4o-mini');
expect(assistantMsg1.sender).toBe('GPT-4');
expect(assistantMsg1.isCreatedByUser).toBe(false);
expect(assistantMsg1.model).toBe('gpt-4');
expect(assistantMsg2.sender).toBe('GPT-3.5-turbo');
expect(assistantMsg2.isCreatedByUser).toBe(false);
expect(assistantMsg2.model).toBe('gpt-3.5-turbo');
});
it('should correctly extract and format model names from various model slugs', async () => {
/**
* Test data with various model slugs to test dynamic model identifier extraction
*/
const testData = [
{
title: 'Dynamic Model Identifier Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['msg-1'],
},
'msg-1': {
id: 'msg-1',
message: {
id: 'msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['Test message'] },
metadata: {},
},
parent: 'root-node',
children: ['msg-2', 'msg-3', 'msg-4', 'msg-5', 'msg-6', 'msg-7', 'msg-8', 'msg-9'],
},
'msg-2': {
id: 'msg-2',
message: {
id: 'msg-2',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['GPT-4 response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'msg-1',
children: [],
},
'msg-3': {
id: 'msg-3',
message: {
id: 'msg-3',
author: { role: 'assistant' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['GPT-4o response'] },
metadata: { model_slug: 'gpt-4o' },
},
parent: 'msg-1',
children: [],
},
'msg-4': {
id: 'msg-4',
message: {
id: 'msg-4',
author: { role: 'assistant' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['GPT-4o-mini response'] },
metadata: { model_slug: 'gpt-4o-mini' },
},
parent: 'msg-1',
children: [],
},
'msg-5': {
id: 'msg-5',
message: {
id: 'msg-5',
author: { role: 'assistant' },
create_time: 1714585035.150442,
content: { content_type: 'text', parts: ['GPT-3.5-turbo response'] },
metadata: { model_slug: 'gpt-3.5-turbo' },
},
parent: 'msg-1',
children: [],
},
'msg-6': {
id: 'msg-6',
message: {
id: 'msg-6',
author: { role: 'assistant' },
create_time: 1714585036.150442,
content: { content_type: 'text', parts: ['GPT-4-turbo response'] },
metadata: { model_slug: 'gpt-4-turbo' },
},
parent: 'msg-1',
children: [],
},
'msg-7': {
id: 'msg-7',
message: {
id: 'msg-7',
author: { role: 'assistant' },
create_time: 1714585037.150442,
content: { content_type: 'text', parts: ['GPT-4-1106-preview response'] },
metadata: { model_slug: 'gpt-4-1106-preview' },
},
parent: 'msg-1',
children: [],
},
'msg-8': {
id: 'msg-8',
message: {
id: 'msg-8',
author: { role: 'assistant' },
create_time: 1714585038.150442,
content: { content_type: 'text', parts: ['Claude response'] },
metadata: { model_slug: 'claude-3-opus' },
},
parent: 'msg-1',
children: [],
},
'msg-9': {
id: 'msg-9',
message: {
id: 'msg-9',
author: { role: 'assistant' },
create_time: 1714585039.150442,
content: { content_type: 'text', parts: ['No model slug response'] },
metadata: {},
},
parent: 'msg-1',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
// Test various GPT model slug formats
const gpt4 = savedMessages.find((msg) => msg.text === 'GPT-4 response');
expect(gpt4.sender).toBe('GPT-4');
expect(gpt4.model).toBe('gpt-4');
const gpt4o = savedMessages.find((msg) => msg.text === 'GPT-4o response');
expect(gpt4o.sender).toBe('GPT-4o');
expect(gpt4o.model).toBe('gpt-4o');
const gpt4oMini = savedMessages.find((msg) => msg.text === 'GPT-4o-mini response');
expect(gpt4oMini.sender).toBe('GPT-4o-mini');
expect(gpt4oMini.model).toBe('gpt-4o-mini');
const gpt35Turbo = savedMessages.find((msg) => msg.text === 'GPT-3.5-turbo response');
expect(gpt35Turbo.sender).toBe('GPT-3.5-turbo');
expect(gpt35Turbo.model).toBe('gpt-3.5-turbo');
const gpt4Turbo = savedMessages.find((msg) => msg.text === 'GPT-4-turbo response');
expect(gpt4Turbo.sender).toBe('GPT-4-turbo');
expect(gpt4Turbo.model).toBe('gpt-4-turbo');
const gpt4Preview = savedMessages.find((msg) => msg.text === 'GPT-4-1106-preview response');
expect(gpt4Preview.sender).toBe('GPT-4-1106-preview');
expect(gpt4Preview.model).toBe('gpt-4-1106-preview');
// Test non-GPT model (should use the model slug as sender)
const claude = savedMessages.find((msg) => msg.text === 'Claude response');
expect(claude.sender).toBe('claude-3-opus');
expect(claude.model).toBe('claude-3-opus');
// Test missing model slug (should default to openAISettings.model.default)
const noModel = savedMessages.find((msg) => msg.text === 'No model slug response');
// When no model slug is provided, it defaults to gpt-4o-mini which gets formatted to GPT-4o-mini
expect(noModel.sender).toBe('GPT-4o-mini');
expect(noModel.model).toBe(openAISettings.model.default);
// Verify user message is unaffected
const userMsg = savedMessages.find((msg) => msg.text === 'Test message');
expect(userMsg.sender).toBe('user');
expect(userMsg.isCreatedByUser).toBe(true);
});
});
describe('importLibreChatConvo', () => {

View File

@@ -1,11 +1,10 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, isEnabled } = require('@librechat/api');
const { logAxiosError, isEnabled, readFileAsString } = require('@librechat/api');
/**
* Sends an email using Mailgun API.
@@ -93,8 +92,7 @@ const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
*/
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
try {
// Read and compile the email template
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const { content: source } = await readFileAsString(path.join(__dirname, 'emails', template));
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);

View File

@@ -1,10 +1,10 @@
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Strategy: AppleStrategy } = require('passport-apple');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
const { User } = require('~/db/models');
@@ -17,6 +17,8 @@ jest.mock('@librechat/data-schemas', () => {
logger: {
error: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
};
});
@@ -24,12 +26,19 @@ jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
describe('Apple Login Strategy', () => {
let mongoServer;
@@ -288,7 +297,14 @@ describe('Apple Login Strategy', () => {
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null);
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
null,
expect.objectContaining({
fileStrategy: 'local',
balance: { enabled: false },
}),
);
});
it('should handle missing idToken gracefully', async () => {

View File

@@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getAppConfig } = require('~/server/services/Config');
const {
@@ -108,7 +109,8 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
const username =
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
const mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
let mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
mail = Array.isArray(mail) ? mail[0] : mail;
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
logger.warn(
@@ -121,9 +123,18 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
);
}
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
user = {
provider: 'ldap',
ldapId,
@@ -133,7 +144,6 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
name: fullName,
role,
};
const appConfig = await getAppConfig({ role: user?.role });
const balanceConfig = getBalanceConfig(appConfig);
const userId = await createUser(user, balanceConfig);
user._id = userId;

View File

@@ -0,0 +1,186 @@
// --- Mocks ---
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@librechat/api', () => ({
// isEnabled used for TLS flags
isEnabled: jest.fn(() => false),
getBalanceConfig: jest.fn(() => ({ enabled: false })),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
countUsers: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/server/services/domains', () => ({
isEmailDomainAllowed: jest.fn(() => true),
}));
// Mock passport-ldapauth to capture verify callback
let verifyCallback;
jest.mock('passport-ldapauth', () => {
return jest.fn().mockImplementation((options, verify) => {
verifyCallback = verify; // capture the strategy verify function
return { name: 'ldap', options, verify };
});
});
const { ErrorTypes } = require('librechat-data-provider');
const { findUser, createUser, updateUser, countUsers } = require('~/models');
const { isEmailDomainAllowed } = require('~/server/services/domains');
// Helper to call the verify callback and wrap in a Promise for convenience
const callVerify = (userinfo) =>
new Promise((resolve, reject) => {
verifyCallback(userinfo, (err, user, info) => {
if (err) return reject(err);
resolve({ user, info });
});
});
describe('ldapStrategy', () => {
beforeEach(() => {
jest.clearAllMocks();
// minimal required env for ldapStrategy module to export
process.env.LDAP_URL = 'ldap://example.com';
process.env.LDAP_USER_SEARCH_BASE = 'ou=users,dc=example,dc=com';
// Unset optional envs to exercise defaults
delete process.env.LDAP_CA_CERT_PATH;
delete process.env.LDAP_FULL_NAME;
delete process.env.LDAP_ID;
delete process.env.LDAP_USERNAME;
delete process.env.LDAP_EMAIL;
delete process.env.LDAP_TLS_REJECT_UNAUTHORIZED;
delete process.env.LDAP_STARTTLS;
// Default model/domain mocks
findUser.mockReset().mockResolvedValue(null);
createUser.mockReset().mockResolvedValue('newUserId');
updateUser.mockReset().mockImplementation(async (id, user) => ({ _id: id, ...user }));
countUsers.mockReset().mockResolvedValue(0);
isEmailDomainAllowed.mockReset().mockReturnValue(true);
// Ensure requiring the strategy sets up the verify callback
jest.isolateModules(() => {
require('./ldapStrategy');
});
});
it('uses the first email when LDAP returns multiple emails (array)', async () => {
const userinfo = {
uid: 'uid123',
givenName: 'Alice',
cn: 'Alice Doe',
mail: ['first@example.com', 'second@example.com'],
};
const { user } = await callVerify(userinfo);
expect(user.email).toBe('first@example.com');
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'ldap',
ldapId: 'uid123',
username: 'Alice',
email: 'first@example.com',
emailVerified: true,
name: 'Alice Doe',
}),
expect.any(Object),
);
});
it('blocks login if an existing user has a different provider', async () => {
findUser.mockResolvedValue({ _id: 'u1', email: 'first@example.com', provider: 'google' });
const userinfo = {
uid: 'uid123',
mail: 'first@example.com',
givenName: 'Alice',
cn: 'Alice Doe',
};
const { user, info } = await callVerify(userinfo);
expect(user).toBe(false);
expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED });
expect(createUser).not.toHaveBeenCalled();
});
it('updates an existing ldap user with current LDAP info', async () => {
const existing = {
_id: 'u2',
provider: 'ldap',
email: 'old@example.com',
ldapId: 'uid123',
username: 'olduser',
name: 'Old Name',
};
findUser.mockResolvedValue(existing);
const userinfo = {
uid: 'uid123',
mail: 'new@example.com',
givenName: 'NewFirst',
cn: 'NewFirst NewLast',
};
const { user } = await callVerify(userinfo);
expect(createUser).not.toHaveBeenCalled();
expect(updateUser).toHaveBeenCalledWith(
'u2',
expect.objectContaining({
provider: 'ldap',
ldapId: 'uid123',
email: 'new@example.com',
username: 'NewFirst',
name: 'NewFirst NewLast',
}),
);
expect(user.email).toBe('new@example.com');
});
it('falls back to username@ldap.local when no email attributes are present', async () => {
const userinfo = {
uid: 'uid999',
givenName: 'John',
cn: 'John Doe',
// no mail and no custom LDAP_EMAIL
};
const { user } = await callVerify(userinfo);
expect(user.email).toBe('John@ldap.local');
});
it('denies login if email domain is not allowed', async () => {
isEmailDomainAllowed.mockReturnValue(false);
const userinfo = {
uid: 'uid123',
mail: 'notallowed@blocked.com',
givenName: 'Alice',
cn: 'Alice Doe',
};
const { user, info } = await callVerify(userinfo);
expect(user).toBe(false);
expect(info).toEqual({ message: 'Email domain not allowed' });
});
});

View File

@@ -41,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
},
/**
* @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done
*/
async (payload, done) => {
try {
const { user, error, migration } = await findOpenIDUser({
openidId: payload?.sub,
email: payload?.email,
strategyName: 'openIdJwtLogin',
findUser,
email: payload?.email,
openidId: payload?.sub,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',
});
if (error) {

View File

@@ -15,6 +15,7 @@ const {
getBalanceConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
@@ -183,7 +184,7 @@ const getUserInfo = async (config, accessToken, sub) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
} catch (error) {
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
return null;
}
};
@@ -336,14 +337,32 @@ async function setupOpenId() {
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
usePKCE,
},
/**
* @param {import('openid-client').TokenEndpointResponseHelpers} tokenset
* @param {import('passport-jwt').VerifyCallback} done
*/
async (tokenset, done) => {
try {
const claims = tokenset.claims();
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const result = await findOpenIDUser({
openidId: claims.sub,
email: claims.email,
strategyName: 'openidStrategy',
findUser,
email: claims.email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'openidStrategy',
});
let user = result.user;
const error = result.error;
@@ -353,13 +372,14 @@ async function setupOpenId() {
message: ErrorTypes.AUTH_FAILED,
});
}
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
const requiredRoles = requiredRole
.split(',')
.map((role) => role.trim())
.filter(Boolean);
let decodedToken = '';
if (requiredRoleTokenKind === 'access') {
decodedToken = jwtDecode(tokenset.access_token);
@@ -382,9 +402,13 @@ async function setupOpenId() {
);
}
if (!roles.includes(requiredRole)) {
if (!requiredRoles.some((role) => roles.includes(role))) {
const rolesList =
requiredRoles.length === 1
? `"${requiredRoles[0]}"`
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
message: `You must have ${rolesList} role to log in.`,
});
}
}
@@ -409,7 +433,6 @@ async function setupOpenId() {
idOnTheSource: userinfo.oid,
};
const appConfig = await getAppConfig();
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
@@ -418,6 +441,10 @@ async function setupOpenId() {
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
if (userinfo.email && userinfo.email !== user.email) {
user.email = userinfo.email;
user.emailVerified = userinfo.email_verified || false;
}
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
@@ -438,7 +465,9 @@ async function setupOpenId() {
userinfo.sub,
);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),

View File

@@ -274,10 +274,7 @@ describe('setupOpenId', () => {
name: '',
};
findUser.mockImplementation(async (query) => {
if (
query.openidId === tokenset.claims().sub ||
(query.email === tokenset.claims().email && query.provider === 'openid')
) {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
@@ -338,7 +335,25 @@ describe('setupOpenId', () => {
// Assert verify that the strategy rejects login
expect(user).toBe(false);
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
expect(details.message).toBe('You must have "requiredRole" role to log in.');
});
it('should allow login when single required role is present (backward compatibility)', async () => {
// Arrange ensure single role configuration (as set in beforeEach)
// OPENID_REQUIRED_ROLE = 'requiredRole'
// Default jwtDecode mock in beforeEach already returns this role
jwtDecode.mockReturnValue({
roles: ['requiredRole', 'anotherRole'],
});
// Act
const { user } = await validate(tokenset);
// Assert verify that login succeeds with single role configuration
expect(user).toBeTruthy();
expect(user.email).toBe(tokenset.claims().email);
expect(user.username).toBe(tokenset.claims().preferred_username);
expect(createUser).toHaveBeenCalled();
});
it('should attempt to download and save the avatar if picture is provided', async () => {
@@ -364,6 +379,58 @@ describe('setupOpenId', () => {
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should support comma-separated multiple roles', async () => {
// Arrange
process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
await setupOpenId(); // Re-initialize the strategy
verifyCallback = require('openid-client/passport').__getVerifyCallback();
jwtDecode.mockReturnValue({
roles: ['anotherRole', 'aThirdRole'],
});
// Act
const { user } = await validate(tokenset);
// Assert
expect(user).toBeTruthy();
expect(user.email).toBe(tokenset.claims().email);
});
it('should reject login when user has none of the required multiple roles', async () => {
// Arrange
process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
await setupOpenId(); // Re-initialize the strategy
verifyCallback = require('openid-client/passport').__getVerifyCallback();
jwtDecode.mockReturnValue({
roles: ['aThirdRole', 'aFourthRole'],
});
// Act
const { user, details } = await validate(tokenset);
// Assert
expect(user).toBe(false);
expect(details.message).toBe(
'You must have one of: "someRole", "anotherRole", "admin" role to log in.',
);
});
it('should handle spaces in comma-separated roles', async () => {
// Arrange
process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin ';
await setupOpenId(); // Re-initialize the strategy
verifyCallback = require('openid-client/passport').__getVerifyCallback();
jwtDecode.mockReturnValue({
roles: ['someRole'],
});
// Act
const { user } = await validate(tokenset);
// Assert
expect(user).toBeTruthy();
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;

View File

@@ -3,7 +3,6 @@ const { FileSources } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser, createUser, getUserById } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
@@ -12,14 +11,15 @@ const { getAppConfig } = require('~/server/services/Config');
*
* @param {IUser} oldUser - The existing user object that needs to be updated.
* @param {string} avatarUrl - The new avatar URL to be set for the user.
* @param {AppConfig} appConfig - The application configuration object.
*
* @returns {Promise<void>}
* The function updates the user's avatar and saves the user object. It does not return any value.
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser, avatarUrl) => {
const fileStrategy = process.env.CDN_PROVIDER;
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = false;
@@ -56,6 +56,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
* @param {string} params.providerId - The provider-specific ID of the user.
* @param {string} params.username - The username of the new user.
* @param {string} params.name - The name of the new user.
* @param {AppConfig} appConfig - The application configuration object.
* @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
*
* @returns {Promise<User>}
@@ -71,6 +72,7 @@ const createSocialUser = async ({
providerId,
username,
name,
appConfig,
emailVerified,
}) => {
const update = {
@@ -83,10 +85,9 @@ const createSocialUser = async ({
emailVerified,
};
const appConfig = await getAppConfig();
const balanceConfig = getBalanceConfig(appConfig);
const newUserId = await createUser(update, balanceConfig);
const fileStrategy = process.env.CDN_PROVIDER;
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
if (!isLocal) {

View File

@@ -7,6 +7,7 @@ const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const paths = require('~/config/paths');
@@ -192,16 +193,25 @@ async function setupSaml() {
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
logger.debug('[samlStrategy] SAML profile:', profile);
const userEmail = getEmail(profile) || '';
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
let user = await findUser({ samlId: profile.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
);
if (!user) {
const email = getEmail(profile) || '';
user = await findUser({ email });
user = await findUser({ email: userEmail });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile.email}`,
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`,
);
}
@@ -225,11 +235,10 @@ async function setupSaml() {
provider: 'saml',
samlId: profile.nameID,
username,
email: getEmail(profile) || '',
email: userEmail,
emailVerified: true,
name: fullName,
};
const appConfig = await getAppConfig();
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
@@ -250,7 +259,9 @@ async function setupSaml() {
fileName = profile.nameID + '.png';
}
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),

Some files were not shown because too many files have changed in this diff Show More