Compare commits

..

167 Commits

Author SHA1 Message Date
constanttime
d6500f7eb3 feat: Add configurable thinking indicator text
- Add thinkingIndicatorText field to interface configuration schema
- Update ContentParts component to use configurable text instead of hardcoded translation
- Update example config with new option
- Maintain backward compatibility by falling back to default translation
2025-10-04 15:15:18 +05:30
github-actions[bot]
341435fb25 🌍 i18n: Update translation.json with latest translations (#9932)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-01 23:31:23 -04:00
Danny Avila
dbe4dd96b4 🧹 chore: Cleanup Logger and Utility Imports (#9935)
* 🧹 chore: Update logger imports to use @librechat/data-schemas across multiple files and remove unused sleep function from queue.js (#9930)

* chore: Replace local isEnabled utility with @librechat/api import across multiple files, update test files

* chore: Replace local logger import with @librechat/data-schemas logger in countTokens.js and fork.js

* chore: Update logs volume path in docker-compose.yml to correct directory

* chore: import order of isEnabled in static.js
2025-10-01 23:30:47 -04:00
Danny Avila
b7d13cec6f v0.8.0 (#9929)
*  v0.8.0

* 🔧 chore: Update config version to 1.3.0

* 🔧 chore: Bump @librechat/api version to 1.4.1

* 🔧 chore: Update @librechat/client version to 0.3.1

* 🔧 chore: Bump librechat-data-provider version to 0.8.020

* 🔧 chore: Bump @librechat/data-schemas version to 0.0.23
2025-10-01 18:00:56 -04:00
Danny Avila
37321ea10d 👨‍🚀 chore: Add Newer OpenAI Models to Default List (#9926) 2025-10-01 10:06:35 -04:00
WhammyLeaf
17ab91f1fd 🔓 feat: Expose Env Field in Helm Deployment Template (#9890)
* Add support for extra secrets and config maps in helm deployment template

* expose single env field in values field instead of extra secret and config map fields

* a small fix
2025-10-01 09:32:19 -04:00
Danny Avila
4777bd22c5 Revert "🚉 feat: MCP Registry Individual Server Init (#9887)"
This reverts commit b8720a9b7a.
2025-09-30 09:39:19 -04:00
normunds-wipo
dfe236acb5 📂 fix: Allow text/xml mimetype (#9908) 2025-09-30 08:49:41 -04:00
Federico Ruggi
c5d1861acf 🔧 fix: Ensure getServerToolFunctions Handles Errors (#9895)
* ensure getServerToolFunctions handles errors

* remove reduntant test
2025-09-30 08:48:39 -04:00
Federico Ruggi
b8720a9b7a 🚉 feat: MCP Registry Individual Server Init (#9887)
* initialize servers sequentially

* adjust for exported properties that are not nullable anymore

* use underscore separator

* mock with set

* customize init timeout via env var
2025-09-29 21:24:41 -04:00
linnil1
0b2fde73e3 ❇️ feat: Add Gemini 2.5 Default Models & Pricing (#9892)
* feat: Add Gemini 2.5 models support

* feat: Remove deprecated Gemini models
2025-09-29 21:23:28 -04:00
Danny Avila
c19b8755a7 🤖 feat: Claude Sonnet 4.5, DeepSeek V3.2 Context & Pricing (#9894)
* feat: Add new Claude models to sharedAnthropicModels list

* chore: use correct claude aliases for default list

* chore: update deepseek model rates for accuracy

* chore: update @librechat/agents dependency to version 2.4.82
2025-09-29 21:09:26 -04:00
github-actions[bot]
f6e19d8034 🌍 i18n: Update translation.json with latest translations (#9869)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-29 09:12:50 -04:00
Danny Avila
c0eb19730a 🪙 refactor: Auth Token Retrieval with Sorting and Query Options (#9884) 2025-09-29 09:06:40 -04:00
Danny Avila
a1471c2f37 📧 fix: Case-Insensitive Domain Matching (#9868)
* chore: move domain related functions to `packages/api`

* fix: isEmailDomainAllowed for case-insensitive domain matching

- Added tests to validate case-insensitive matching for email domains in various scenarios.
- Updated isEmailDomainAllowed function to convert email domains to lowercase for consistent comparison.
- Improved handling of null/undefined entries in allowedDomains.

* ci: Mock isEmailDomainAllowed in samlStrategy tests

- Added a mock implementation for isEmailDomainAllowed to return true in samlStrategy tests, ensuring consistent behavior during test execution.

* ci: Update import of isEmailDomainAllowed in ldapStrategy tests

- Changed the import of isEmailDomainAllowed from the domains service to the api package for consistency and to reflect recent refactoring.
2025-09-27 21:20:19 -04:00
Danny Avila
712f0b3ca2 📌 fix: Exclude Pinned Keys from Cleanup and Fix MCP Pin State (#9867)
* fix: Prevent MCPSelect from rendering when not pinned and no values are available

* fix: Exclude 'pinned' keys from timestamped storage cleanup logic

* fix: Safeguard MCPSelect rendering by adding optional chaining for mcpValues
2025-09-27 17:21:48 -04:00
MyGitHub
062d813b21 ☸️ feat: Helm hostAliases Support For Custom DNS Mappings (#9857)
Add ability to configure hostAliases in Helm chart to redirect traffic to proxy servers or custom endpoints via /etc/hosts entries.

Co-authored-by: Feng Lu <feng.lu@kindredgroup.com>
2025-09-27 10:49:36 -04:00
Danny Avila
4b5b46604c 🔍 refactor: OCR Fully Optional with Defaults for "Upload as Text" (#9856)
* refactor: move `loadOCRConfig` from `packages/data-provider` to `packages/api` and return `undefined` if not explicitly configured

* fix: loadOCRConfig import from @librechat/api

* refactor: update defaultTextMimeTypes to support virtually all file types for text parsing

* fix: improve OCR capability check and error message for unsupported file types

* ci: remove unnecessary ocr expectation from AppService test
2025-09-26 11:56:11 -04:00
Danny Avila
3d7eaf0fcc 🌐 feat: OpenRouter Web Search (#9853)
* 🌐 feat: OpenRouter Web Search

- Added tests for handling web_search parameter with OpenRouter in various scenarios.
- Implemented logic to manage web_search in modelOptions and addParams/dropParams.
- Ensured correct configuration of llmConfig and modelKwargs for OpenRouter, including handling of plugins.
- Improved overall integration of OpenRouter with OpenAI API, ensuring expected behavior across different configurations.

* chore: bump @librechat/agents to v2.4.81
2025-09-26 09:35:41 -04:00
Danny Avila
823015160c 🕸️ refactor: Drop/Add web_search Param Handling for Custom Endpoints (#9852)
- Added tests to validate behavior of web_search parameter in getOpenAIConfig function.
- Implemented logic to handle web_search in addParams and dropParams, ensuring correct precedence and behavior.
- Ensured web_search does not appear in modelKwargs or llmConfig when not applicable.
- Improved overall configuration management for OpenAI API integration.
2025-09-26 08:56:39 -04:00
Theo N. Truong
3219734b9e 🔌 fix: Shared MCP Server Connection Management (#9822)
- Fixed a bug in reinitMCPServer where a user connection was created for an app-level server whenever this server is reinitialized
- Made MCPManager.getUserConnection to return an error if the connection is app-level
- Add MCPManager.getConnection to return either an app connection or a user connection based on the serverName
- Made MCPManager.appConnections public to avoid unnecessary wrapper methods.
2025-09-26 08:24:36 -04:00
Danny Avila
4f3683fd9a 👤 fix: Missing User Placeholder Fields for MCP Services (#9824) 2025-09-24 22:48:38 -04:00
Danny Avila
57f8b333bc 🕵️ refactor: Optimize Message Search Performance (#9818)
* 🕵️ feat: Enhance Index Sync and MeiliSearch filtering for User Field

- Implemented `ensureFilterableAttributes` function to configure MeiliSearch indexes for messages and conversations to filter by user.
- Updated sync logic to trigger a full re-sync if the user field is missing or index settings are modified.
- Adjusted search queries in Conversation and Message models to include user filtering.
- Ensured 'user' field is marked as filterable in MongoDB schema for both messages and conversations.

This update improves data integrity and search capabilities by ensuring user-related data is properly indexed and retrievable.

* fix: message processing in Search component to use linear list and not tree

* feat: Implement user filtering in MeiliSearch for shared links

* refactor: Optimize message search retrieval by batching database calls

* chore: Update MeiliSearch parameters type to use SearchParams for improved type safety
2025-09-24 16:27:34 -04:00
Danny Avila
f9aebeba92 🛡️ fix: Title Generation Skip Logic Based On Endpoint Config (#9811) 2025-09-24 10:21:19 -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
Danny Avila
15d7a3d221 🎵 feat: Cumulative Transcription Support for External STT (#9318)
* 🔧 fix: TTS and STT Services to use AppConfig

- Updated `getProviderSchema` and `getProvider` methods to accept an optional `appConfig` parameter, allowing for more flexible configuration retrieval.
- Improved error handling by ensuring that the app configuration is checked before accessing TTS and STT schemas.
- Refactored `processTextToSpeech` and `streamAudio` methods to utilize the new `appConfig` parameter for better clarity and maintainability.

* feat: Cumulative Transcription Support for STT External

* style: fix medium-sized styling for admin settings dialogs
2025-08-27 18:56:04 -04:00
Danny Avila
c3e88b97c8 🎤 feat: Cumulative Transcription Support for AudioRecorder (#9316)
- Added useRef to maintain existing text during audio recording.
- Updated setText to prepend existing text to new transcriptions.
- Modified handleStartRecording and handleStopRecording to manage existing text state.
- Improved spinner icon styling for better visibility.
2025-08-27 18:00:59 -04:00
Danny Avila
ba424666f8 🔐 feat: Add Configurable Min. Password Length (#9315)
- Added support for a minimum password length defined by the MIN_PASSWORD_LENGTH environment variable.
- Updated login, registration, and reset password forms to utilize the configured minimum length.
- Enhanced validation schemas to reflect the new minimum password length requirement.
- Included tests to ensure the minimum password length functionality works as expected.
2025-08-27 16:30:56 -04:00
MarcAmick
ea3b671182 🔧 feat: Alternative DNS Lookup for AWS ElastiCache TLS Connections (#9264)
* added REDIS_USE_ALTERNATIVE_DNS_LOOKUP env variable to modify redis connection by adding dnsLookup
this is required when connecting to elasticache for ioredis
see "Special Note: Aws Elasticache Clusters with TLS" on this webpage:  https://www.npmjs.com/package/ioredis

* added REDIS_USE_ALTERNATIVE_DNS_LOOKUP env variable to modify redis connection by adding dnsLookup
this is required when connecting to elasticache for ioredis
see "Special Note: Aws Elasticache Clusters with TLS" on this webpage:  https://www.npmjs.com/package/ioredis

---------

Co-authored-by: Marc Amick <MarcAmick@jhu.edu>
2025-08-27 16:09:07 -04:00
Dustin Healy
f209f616c9 🌍 i18n: Add Slovenian Language (#9313) 2025-08-27 14:02:22 -04:00
colinlin-stripe
961af515d5 🧹 chore: [stripe] remove dangerously set html (#9288) 2025-08-27 13:58:07 -04:00
Danny Avila
a362963017 🐛 fix: String Interpolation in Messages Endpoint from #9155 (#9312)
* feat: move buildTree function for message hierarchy to data provider

* refactor: consolidate buildTree import from utils to data provider

* fix: correct string interpolation in messages function, which caused message search requests to fail
2025-08-27 13:48:48 -04:00
Danny Avila
78d735f35c 📧 fix: Missing Email fallback in openIdJwtLogin (#9311)
* 📧 fix: Missing Email fallback in `openIdJwtLogin`

* chore: Add auth module export to index
2025-08-27 12:59:40 -04:00
530 changed files with 30464 additions and 7505 deletions

View File

@@ -40,6 +40,13 @@ NO_INDEX=true
# Defaulted to 1.
TRUST_PROXY=1
# Minimum password length for user authentication
# Default: 8
# Note: When using LDAP authentication, you may want to set this to 1
# to bypass local password validation, as LDAP servers handle their own
# password policies.
# MIN_PASSWORD_LENGTH=8
#===============#
# JSON Logging #
#===============#
@@ -156,10 +163,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash,gemini-2.0-flash-lite
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@@ -660,6 +667,10 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_URI=rediss://127.0.0.1:6380
# REDIS_CA=/path/to/ca-cert.pem
# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis
# Enable alternative dnsLookup for redis
# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true
# Redis authentication (if required)
# REDIS_USERNAME=your_redis_username
# REDIS_PASSWORD=your_redis_password
@@ -679,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
# 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
# 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

@@ -1,4 +1,5 @@
const Anthropic = require('@anthropic-ai/sdk');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const {
Constants,
@@ -9,8 +10,18 @@ const {
getResponseSender,
validateVisionModel,
} = require('librechat-data-provider');
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api');
const { sleep, SplitStreamHandler: _Handler } = require('@librechat/agents');
const {
Tokenizer,
createFetch,
matchModelName,
getClaudeHeaders,
getModelMaxTokens,
configureReasoning,
checkPromptCacheSupport,
getModelMaxOutputTokens,
createStreamEventHandlers,
} = require('@librechat/api');
const {
truncateText,
formatMessage,
@@ -19,17 +30,9 @@ 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');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
const HUMAN_PROMPT = '\n\nHuman:';
const AI_PROMPT = '\n\nAssistant:';

View File

@@ -1,4 +1,7 @@
const { google } = require('googleapis');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
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,9 +24,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 {
formatMessage,
createContextHandlers,

View File

@@ -1,13 +1,15 @@
const { OllamaClient } = require('./OllamaClient');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
const { sleep, SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
const {
isEnabled,
Tokenizer,
createFetch,
resolveHeaders,
constructAzureURL,
getModelMaxTokens,
genAzureChatCompletion,
getModelMaxOutputTokens,
createStreamEventHandlers,
} = require('@librechat/api');
const {
@@ -31,17 +33,17 @@ 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 { addSpaceIfNeeded } = require('~/server/utils');
const { handleOpenAIErrors } = require('./tools/util');
const { OllamaClient } = require('./OllamaClient');
const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains');
const { extractBaseURL } = require('~/utils');
const { tokenSplit } = require('./document');
const BaseClient = require('./BaseClient');
const { createLLM } = require('./llm');
const { logger } = require('~/config');
class OpenAIClient extends BaseClient {
constructor(apiKey, options = {}) {

View File

@@ -1,5 +1,5 @@
const { Readable } = require('stream');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
class TextStream extends Readable {
constructor(text, options = {}) {

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { ZeroShotAgentOutputParser } = require('langchain/agents');
const { logger } = require('~/config');
class CustomOutputParser extends ZeroShotAgentOutputParser {
constructor(fields) {

View File

@@ -1,7 +1,7 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { langPrompt, createTitlePrompt, escapeBraces, getSnippet } = require('../prompts');
const { createStructuredOutputChainFromZod } = require('langchain/chains/openai_functions');
const { logger } = require('~/config');
const langSchema = z.object({
language: z.string().describe('The language of the input text (full noun, no abbreviations).'),

View File

@@ -1,7 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { ConversationSummaryBufferMemory, ChatMessageHistory } = require('langchain/memory');
const { formatLangChainMessages, SUMMARY_PROMPT } = require('../prompts');
const { predictNewSummary } = require('../chains');
const { logger } = require('~/config');
const createSummaryBufferMemory = ({ llm, prompt, messages, ...rest }) => {
const chatHistory = new ChatMessageHistory(messages);

View File

@@ -1,4 +1,4 @@
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
/**
* The `addImages` function corrects any erroneous image URLs in the `responseMessage.text`

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

@@ -1,7 +1,7 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { SearchClient, AzureKeyCredential } = require('@azure/search-documents');
const { logger } = require('~/config');
class AzureAISearch extends Tool {
// Constants for default values
@@ -18,7 +18,7 @@ class AzureAISearch extends Tool {
super();
this.name = 'azure-ai-search';
this.description =
'Use the \'azure-ai-search\' tool to retrieve search results relevant to your input';
"Use the 'azure-ai-search' tool to retrieve search results relevant to your input";
/* Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;

View File

@@ -3,12 +3,12 @@ const axios = require('axios');
const fetch = require('node-fetch');
const { v4: uuidv4 } = require('uuid');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const { logger } = require('~/config');
const displayMessage =
'Flux displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.';
"Flux displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
/**
* FluxAPI - A tool for generating high-quality images from text prompts using the Flux API.

View File

@@ -6,9 +6,9 @@ const axios = require('axios');
const sharp = require('sharp');
const { v4: uuidv4 } = require('uuid');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const paths = require('~/config/paths');
const { logger } = require('~/config');
const displayMessage =
"Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";

View File

@@ -1,7 +1,7 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const { logger } = require('~/config');
/**
* Tool for the Traversaal AI search API, Ares.
@@ -21,7 +21,7 @@ class TraversaalSearch extends Tool {
query: z
.string()
.describe(
'A properly written sentence to be interpreted by an AI to search the web according to the user\'s request.',
"A properly written sentence to be interpreted by an AI to search the web according to the user's request.",
),
});
@@ -38,7 +38,6 @@ class TraversaalSearch extends Tool {
return apiKey;
}
// eslint-disable-next-line no-unused-vars
async _call({ query }, _runManager) {
const body = {
query: [query],

View File

@@ -1,8 +1,8 @@
/* eslint-disable no-useless-escape */
const axios = require('axios');
const { z } = require('zod');
const axios = require('axios');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
class WolframAlphaAPI extends Tool {
constructor(fields) {

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,5 +1,5 @@
const OpenAI = require('openai');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
/**
* Handles errors that may occur when making requests to OpenAI's API.

View File

@@ -1,9 +1,21 @@
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 { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const {
checkAccess,
createSafeUser,
mcpToolPattern,
loadWebSearchAuth,
} = require('@librechat/api');
const {
Tools,
Constants,
Permissions,
EToolResources,
PermissionTypes,
replaceSpecialVars,
} = require('librechat-data-provider');
const {
availableTools,
manifestToolMap,
@@ -26,7 +38,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 +255,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 +293,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 +344,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 +414,65 @@ 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();
const safeUser = createSafeUser(options.req?.user);
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 = {
index,
signal,
user: safeUser,
userMCPAuthMap,
res: options.res,
model: agent?.model ?? model,
serverName: config.serverName,
provider: agent?.provider ?? endpoint,
};
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),
@@ -52,6 +75,9 @@ const cacheConfig = {
REDIS_CONNECT_TIMEOUT: math(process.env.REDIS_CONNECT_TIMEOUT, 10000),
/** Queue commands when disconnected */
REDIS_ENABLE_OFFLINE_QUEUE: isEnabled(process.env.REDIS_ENABLE_OFFLINE_QUEUE ?? 'true'),
/** flag to modify redis connection by adding dnsLookup this is required when connecting to elasticache for ioredis
* see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis **/
REDIS_USE_ALTERNATIVE_DNS_LOOKUP: isEnabled(process.env.REDIS_USE_ALTERNATIVE_DNS_LOOKUP),
/** Enable redis cluster without the need of multiple URIs */
USE_REDIS_CLUSTER: isEnabled(process.env.USE_REDIS_CLUSTER ?? 'false'),
CI: isEnabled(process.env.CI),

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

@@ -1,5 +1,5 @@
const { isEnabled } = require('@librechat/api');
const { Time, CacheKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};

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

@@ -2,7 +2,7 @@
const mongoose = require('mongoose');
const EventEmitter = require('events');
const { GridFSBucket } = require('mongodb');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const storeMap = new Map();

View File

@@ -1,4 +1,4 @@
const { isEnabled } = require('~/server/utils');
const { isEnabled } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const getLogStores = require('./getLogStores');
const banViolation = require('./banViolation');

View File

@@ -53,6 +53,9 @@ if (cacheConfig.USE_REDIS) {
: new IoRedis.Cluster(
urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })),
{
...(cacheConfig.REDIS_USE_ALTERNATIVE_DNS_LOOKUP
? { dnsLookup: (address, callback) => callback(null, address) }
: {}),
redisOptions,
clusterRetryStrategy: (times) => {
if (

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

@@ -1,10 +1,8 @@
const mongoose = require('mongoose');
const { MeiliSearch } = require('meilisearch');
const { logger } = require('@librechat/data-schemas');
const { FlowStateManager } = require('@librechat/api');
const { CacheKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { isEnabled, FlowStateManager } = require('@librechat/api');
const { getLogStores } = require('~/cache');
const Conversation = mongoose.models.Conversation;
@@ -31,6 +29,81 @@ class MeiliSearchClient {
}
}
/**
* Ensures indexes have proper filterable attributes configured and checks if documents have user field
* @param {MeiliSearch} client - MeiliSearch client instance
* @returns {Promise<boolean>} - true if configuration was updated or re-sync is needed
*/
async function ensureFilterableAttributes(client) {
try {
// Check and update messages index
try {
const messagesIndex = client.index('messages');
const settings = await messagesIndex.getSettings();
if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
logger.info('[indexSync] Configuring messages index to filter by user...');
await messagesIndex.updateSettings({
filterableAttributes: ['user'],
});
logger.info('[indexSync] Messages index configured for user filtering');
logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.');
return true;
}
// Check if existing documents have user field indexed
try {
const searchResult = await messagesIndex.search('', { limit: 1 });
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
logger.info('[indexSync] Existing messages missing user field, re-sync needed');
return true;
}
} catch (searchError) {
logger.debug('[indexSync] Could not check message documents:', searchError.message);
}
} catch (error) {
if (error.code !== 'index_not_found') {
logger.warn('[indexSync] Could not check/update messages index settings:', error.message);
}
}
// Check and update conversations index
try {
const convosIndex = client.index('convos');
const settings = await convosIndex.getSettings();
if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
logger.info('[indexSync] Configuring convos index to filter by user...');
await convosIndex.updateSettings({
filterableAttributes: ['user'],
});
logger.info('[indexSync] Convos index configured for user filtering');
logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.');
return true;
}
// Check if existing documents have user field indexed
try {
const searchResult = await convosIndex.search('', { limit: 1 });
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
logger.info('[indexSync] Existing conversations missing user field, re-sync needed');
return true;
}
} catch (searchError) {
logger.debug('[indexSync] Could not check conversation documents:', searchError.message);
}
} catch (error) {
if (error.code !== 'index_not_found') {
logger.warn('[indexSync] Could not check/update convos index settings:', error.message);
}
}
} catch (error) {
logger.error('[indexSync] Error ensuring filterable attributes:', error);
}
return false;
}
/**
* Performs the actual sync operations for messages and conversations
*/
@@ -47,12 +120,27 @@ async function performSync() {
return { messagesSync: false, convosSync: false };
}
/** Ensures indexes have proper filterable attributes configured */
const configUpdated = await ensureFilterableAttributes(client);
let messagesSync = false;
let convosSync = false;
// If configuration was just updated or documents are missing user field, force a full re-sync
if (configUpdated) {
logger.info('[indexSync] Forcing full re-sync to ensure user field is properly indexed...');
// Reset sync flags to force full re-sync
await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } });
await Conversation.collection.updateMany(
{ _meiliIndex: true },
{ $set: { _meiliIndex: false } },
);
}
// Check if we need to sync messages
const messageProgress = await Message.getSyncProgress();
if (!messageProgress.isComplete) {
if (!messageProgress.isComplete || configUpdated) {
logger.info(
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
);
@@ -79,7 +167,7 @@ async function performSync() {
// Check if we need to sync conversations
const convoProgress = await Conversation.getSyncProgress();
if (!convoProgress.isComplete) {
if (!convoProgress.isComplete || configUpdated) {
logger.info(
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
);

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

@@ -1,4 +1,4 @@
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const options = [
{

View File

@@ -174,7 +174,7 @@ module.exports = {
if (search) {
try {
const meiliResults = await Conversation.meiliSearch(search);
const meiliResults = await Conversation.meiliSearch(search, { filter: `user = "${user}"` });
const matchingIds = Array.isArray(meiliResults.hits)
? meiliResults.hits.map((result) => result.conversationId)
: [];

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,47 +1,9 @@
const mongoose = require('mongoose');
const { buildTree } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { getMessages, bulkSaveMessages } = require('./Message');
const { Message } = require('~/db/models');
// Original version of buildTree function
function buildTree({ messages, fileMap }) {
if (messages === null) {
return null;
}
const messageMap = {};
const rootMessages = [];
const childrenCount = {};
messages.forEach((message) => {
const parentId = message.parentMessageId ?? '';
childrenCount[parentId] = (childrenCount[parentId] || 0) + 1;
const extendedMessage = {
...message,
children: [],
depth: 0,
siblingIndex: childrenCount[parentId] - 1,
};
if (message.files && fileMap) {
extendedMessage.files = message.files.map((file) => fileMap[file.file_id ?? ''] ?? file);
}
messageMap[message.messageId] = extendedMessage;
const parentMessage = messageMap[parentId];
if (parentMessage) {
parentMessage.children.push(extendedMessage);
extendedMessage.depth = parentMessage.depth + 1;
} else {
rootMessages.push(extendedMessage);
}
});
return rootMessages;
}
let mongod;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();

View File

@@ -1,4 +1,4 @@
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
/**
* Creates up to two transactions to record the spending of tokens.

View File

@@ -1,4 +1,4 @@
const { matchModelName } = require('../utils/tokens');
const { matchModelName } = require('@librechat/api');
const defaultRate = 6;
/**
@@ -111,8 +111,8 @@ const tokenValues = Object.assign(
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
'deepseek-reasoner': { prompt: 0.55, completion: 2.19 },
deepseek: { prompt: 0.14, completion: 0.28 },
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
deepseek: { prompt: 0.28, completion: 0.42 },
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
@@ -124,7 +124,8 @@ const tokenValues = Object.assign(
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 },
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },

View File

@@ -571,6 +571,9 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.5-pro-preview-05-06',
'gemini-2.5-flash-preview-04-17',
'gemini-2.5-exp',
@@ -611,6 +614,9 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro',
'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash',
'gemini-2.5-exp': 'gemini-2.5',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc3",
"version": "v0.8.0",
"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.82",
"@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,7 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
/**
* @param {ServerRequest} req

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,13 +7,13 @@ const {
createRun,
Tokenizer,
checkAccess,
logAxiosError,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
formatContentStrings,
getTransactionsConfig,
createMemoryProcessor,
encodeAndFormatAudios,
encodeAndFormatVideos,
encodeAndFormatDocuments,
} = require('@librechat/api');
const {
Callback,
@@ -36,7 +36,6 @@ const {
AgentCapabilities,
bedrockInputSchema,
removeNullishValues,
isDocumentSupportedEndpoint,
} = require('librechat-data-provider');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
@@ -44,13 +43,11 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints');
const { getStrategyFunctions } = require('~/server/services/Files');
const { checkCapability } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
const { getFiles } = require('~/models');
const omitTitleOptions = new Set([
'stream',
@@ -92,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 {
@@ -228,168 +224,6 @@ class AgentClient extends BaseClient {
return files;
}
async addDocuments(message, attachments) {
const documentResult = await encodeAndFormatDocuments(
this.options.req,
attachments,
this.options.agent.provider,
getStrategyFunctions,
);
message.documents =
documentResult.documents && documentResult.documents.length
? documentResult.documents
: undefined;
return documentResult.files;
}
async addVideos(message, attachments) {
const videoResult = await encodeAndFormatVideos(
this.options.req,
attachments,
this.options.agent.provider,
getStrategyFunctions,
);
message.videos =
videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined;
return videoResult.files;
}
async addAudios(message, attachments) {
const audioResult = await encodeAndFormatAudios(
this.options.req,
attachments,
this.options.agent.provider,
getStrategyFunctions,
);
message.audios =
audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined;
return audioResult.files;
}
/**
* Override addPreviousAttachments to handle all file types, not just images
* @param {TMessage[]} _messages
* @returns {Promise<TMessage[]>}
*/
async addPreviousAttachments(_messages) {
if (!this.options.resendFiles) {
return _messages;
}
const seen = new Set();
const attachmentsProcessed =
this.options.attachments && !(this.options.attachments instanceof Promise);
if (attachmentsProcessed) {
for (const attachment of this.options.attachments) {
seen.add(attachment.file_id);
}
}
/**
*
* @param {TMessage} message
*/
const processMessage = async (message) => {
if (!this.message_file_map) {
/** @type {Record<string, MongoFile[]> */
this.message_file_map = {};
}
const fileIds = [];
for (const file of message.files) {
if (seen.has(file.file_id)) {
continue;
}
fileIds.push(file.file_id);
seen.add(file.file_id);
}
if (fileIds.length === 0) {
return message;
}
const files = await getFiles(
{
file_id: { $in: fileIds },
},
{},
{},
);
await this.processAttachments(message, files);
this.message_file_map[message.messageId] = files;
return message;
};
const promises = [];
for (const message of _messages) {
if (!message.files) {
promises.push(message);
continue;
}
promises.push(processMessage(message));
}
const messages = await Promise.all(promises);
this.checkVisionRequest(Object.values(this.message_file_map ?? {}).flat());
return messages;
}
async processAttachments(message, attachments) {
const categorizedAttachments = {
images: [],
documents: [],
videos: [],
audios: [],
};
for (const file of attachments) {
if (file.type.startsWith('image/')) {
categorizedAttachments.images.push(file);
} else if (file.type === 'application/pdf') {
categorizedAttachments.documents.push(file);
} else if (file.type.startsWith('video/')) {
categorizedAttachments.videos.push(file);
} else if (file.type.startsWith('audio/')) {
categorizedAttachments.audios.push(file);
}
}
const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([
categorizedAttachments.images.length > 0
? this.addImageURLs(message, categorizedAttachments.images)
: Promise.resolve([]),
categorizedAttachments.documents.length > 0
? this.addDocuments(message, categorizedAttachments.documents)
: Promise.resolve([]),
categorizedAttachments.videos.length > 0
? this.addVideos(message, categorizedAttachments.videos)
: Promise.resolve([]),
categorizedAttachments.audios.length > 0
? this.addAudios(message, categorizedAttachments.audios)
: Promise.resolve([]),
]);
const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles];
const seenFileIds = new Set();
const uniqueFiles = [];
for (const file of allFiles) {
if (file.file_id && !seenFileIds.has(file.file_id)) {
seenFileIds.add(file.file_id);
uniqueFiles.push(file);
} else if (!file.file_id) {
uniqueFiles.push(file);
}
}
return uniqueFiles;
}
async buildMessages(
messages,
parentMessageId,
@@ -423,7 +257,7 @@ class AgentClient extends BaseClient {
};
}
const files = await this.processAttachments(
const files = await this.addImageURLs(
orderedMessages[orderedMessages.length - 1],
attachments,
);
@@ -446,47 +280,6 @@ class AgentClient extends BaseClient {
assistantName: this.options?.modelLabel,
});
const hasFiles =
(message.documents && message.documents.length > 0) ||
(message.videos && message.videos.length > 0) ||
(message.audios && message.audios.length > 0) ||
(message.image_urls && message.image_urls.length > 0);
if (
hasFiles &&
message.isCreatedByUser &&
isDocumentSupportedEndpoint(this.options.agent.provider)
) {
const contentParts = [];
if (message.documents && message.documents.length > 0) {
contentParts.push(...message.documents);
}
if (message.videos && message.videos.length > 0) {
contentParts.push(...message.videos);
}
if (message.audios && message.audios.length > 0) {
contentParts.push(...message.audios);
}
if (message.image_urls && message.image_urls.length > 0) {
contentParts.push(...message.image_urls);
}
if (typeof formattedMessage.content === 'string') {
contentParts.push({ type: 'text', text: formattedMessage.content });
} else {
const textPart = formattedMessage.content.find((part) => part.type === 'text');
if (textPart) {
contentParts.push(textPart);
}
}
formattedMessage.content = contentParts;
}
if (message.ocr && i !== orderedMessages.length - 1) {
if (typeof formattedMessage.content === 'string') {
formattedMessage.content = message.ocr + '\n' + formattedMessage.content;
@@ -831,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,
}) {
@@ -861,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,
@@ -1002,7 +798,6 @@ class AgentClient extends BaseClient {
};
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
this.indexTokenCountMap,
@@ -1077,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);
}
@@ -1089,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,
@@ -1250,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',
@@ -1312,6 +1121,13 @@ class AgentClient extends BaseClient {
);
}
if (endpointConfig?.titleConvo === false) {
logger.debug(
`[api/server/controllers/agents/client.js #titleConvo] Title generation disabled for endpoint "${endpoint}"`,
);
return;
}
if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
try {
titleProviderConfig = getProviderConfig({
@@ -1321,7 +1137,7 @@ class AgentClient extends BaseClient {
endpoint = endpointConfig.titleEndpoint;
} catch (error) {
logger.warn(
`[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for ${endpointConfig.titleEndpoint}, falling back to default`,
`[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for "${endpointConfig.titleEndpoint}", falling back to default`,
error,
);
// Fall back to original provider config
@@ -1391,6 +1207,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,
@@ -1430,11 +1260,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,
},
});
});
@@ -260,6 +263,125 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBeUndefined();
});
it('should skip title generation when titleConvo is set to false', async () => {
// Set titleConvo to false in endpoint config
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titleConvo: false,
titleModel: 'gpt-3.5-turbo',
titlePrompt: 'Custom title prompt',
titleMethod: 'structured',
titlePromptTemplate: 'Template: {{content}}',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation when titleConvo is false in all config', async () => {
// Set titleConvo to false in "all" config
mockReq.config = {
endpoints: {
all: {
titleConvo: false,
titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'All config template',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation when titleConvo is false for custom endpoint scenario', async () => {
// This test validates the behavior when customEndpointConfig (retrieved via
// getProviderConfig for custom endpoints) has titleConvo: false.
//
// The code path is:
// 1. endpoints?.all is checked (undefined in this test)
// 2. endpoints?.[endpoint] is checked (our test config)
// 3. Would fall back to titleProviderConfig.customEndpointConfig (for real custom endpoints)
//
// We simulate a custom endpoint scenario using a dynamically named endpoint config
// Create a unique endpoint name that represents a custom endpoint
const customEndpointName = 'customEndpoint';
// Configure the endpoint to have titleConvo: false
// This simulates what would be in customEndpointConfig for a real custom endpoint
mockReq.config = {
endpoints: {
// No 'all' config - so it will check endpoints[endpoint]
// This config represents what customEndpointConfig would contain
[customEndpointName]: {
titleConvo: false,
titleModel: 'custom-model-v1',
titlePrompt: 'Custom endpoint title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'Custom template: {{content}}',
baseURL: 'https://api.custom-llm.com/v1',
apiKey: 'test-custom-key',
// Additional custom endpoint properties
models: {
default: ['custom-model-v1', 'custom-model-v2'],
},
},
},
};
// Set up agent to use our custom endpoint
// Use openAI as base but override with custom endpoint name for this test
mockAgent.endpoint = EModelEndpoint.openAI;
mockAgent.provider = EModelEndpoint.openAI;
// Override the endpoint in the config to point to our custom config
mockReq.config.endpoints[EModelEndpoint.openAI] =
mockReq.config.endpoints[customEndpointName];
delete mockReq.config.endpoints[customEndpointName];
const text = 'Test custom endpoint conversation';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title because titleConvo is false
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should pass titleEndpoint configuration to generateTitle', async () => {
// Mock the API key just for this test
const originalApiKey = process.env.ANTHROPIC_API_KEY;

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

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const loginController = async (req, res) => {
try {

View File

@@ -1,8 +1,8 @@
const cookies = require('cookie');
const { getOpenIdConfig } = require('~/strategies');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logoutUser } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { getOpenIdConfig } = require('~/strategies');
const logoutController = async (req, res) => {
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;

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

@@ -1,6 +1,6 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
/**
* Checks if the user can delete their account

View File

@@ -1,8 +1,9 @@
const { Keyv } = require('keyv');
const uap = require('ua-parser-js');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, removePorts } = require('~/server/utils');
const { removePorts } = require('~/server/utils');
const keyvMongo = require('~/cache/keyvMongo');
const denyRequest = require('./denyRequest');
const { getLogStores } = require('~/cache');

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { isEmailDomainAllowed } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');
/**
@@ -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,6 @@
const { logger } = require('@librechat/data-schemas');
const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Middleware to check if user has permission to access people picker functionality

View File

@@ -1,10 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
jest.mock('~/models/Role');
jest.mock('~/config', () => ({
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
},

View File

@@ -1,7 +1,7 @@
const { isEnabled } = require('@librechat/api');
const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
const clearPendingReq = require('~/cache/clearPendingReq');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const denyRequest = require('./denyRequest');
const {

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,4 +1,4 @@
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
/**
* Middleware to log Forwarded Headers

View File

@@ -1,8 +1,8 @@
const axios = require('axios');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const denyRequest = require('./denyRequest');
const { logger } = require('~/config');
async function moderateText(req, res, next) {
if (!isEnabled(process.env.OPENAI_MODERATION)) {

View File

@@ -1,6 +1,6 @@
const cookies = require('cookie');
const { isEnabled } = require('~/server/utils');
const passport = require('passport');
const { isEnabled } = require('@librechat/api');
// This middleware does not require authentication,
// but if the user is authenticated, it will set the user object.

View File

@@ -1,6 +1,6 @@
const passport = require('passport');
const cookies = require('cookie');
const { isEnabled } = require('~/server/utils');
const passport = require('passport');
const { isEnabled } = require('@librechat/api');
/**
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse

View File

@@ -1,5 +1,5 @@
const passport = require('passport');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const requireLocalAuth = (req, res, next) => {
passport.authenticate('local', (err, user, info) => {

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

@@ -1,5 +1,5 @@
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
function validatePasswordReset(req, res, next) {
if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {

View File

@@ -1,4 +1,4 @@
const { isEnabled } = require('~/server/utils');
const { isEnabled } = require('@librechat/api');
function validateRegistration(req, res, next) {
if (req.invite) {

View File

@@ -1,10 +1,13 @@
const request = require('supertest');
const express = require('express');
const request = require('supertest');
const { isEnabled } = require('@librechat/api');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { isEnabled } = require('~/server/utils');
jest.mock('~/server/services/Config/ldap');
jest.mock('~/server/utils');
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
}));
const app = express();

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

@@ -1,20 +1,19 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { generateCheckAccess, isActionDomainAllowed } = require('@librechat/api');
const {
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
actionDelimiter,
PermissionBits,
removeNullishValues,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { getAgent, updateAgent, getListAgentsByAccess } = require('~/models/Agent');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');

View File

@@ -1,4 +1,5 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const {
uaParser,
checkBan,
@@ -8,7 +9,6 @@ const {
concurrentLimiter,
messageUserLimiter,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const { v1 } = require('./v1');
const chat = require('./chat');

View File

@@ -1,12 +1,12 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { isActionDomainAllowed } = require('@librechat/api');
const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { updateAssistantDoc, getAssistant } = require('~/models/Assistant');
const { isActionDomainAllowed } = require('~/server/services/domains');
const router = express.Router();

View File

@@ -117,9 +117,16 @@ router.get('/', async function (req, res) {
openidReuseTokens,
};
payload.mcpServers = {};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
const getMCPServers = () => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
@@ -128,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

@@ -1,19 +1,19 @@
const { isEnabled } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const {
validateConvoAccess,
messageUserLimiter,
concurrentLimiter,
messageIpLimiter,
requireJwtAuth,
checkBan,
uaParser,
} = require('~/server/middleware');
const anthropic = require('./anthropic');
const express = require('express');
const openAI = require('./openAI');
const custom = require('./custom');
const google = require('./google');
const anthropic = require('./anthropic');
const { isEnabled } = require('~/server/utils');
const { EModelEndpoint } = require('librechat-data-provider');
const {
checkBan,
uaParser,
requireJwtAuth,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};

View File

@@ -1,6 +1,7 @@
const fs = require('fs').promises;
const express = require('express');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const {
Time,
isUUID,
@@ -30,7 +31,7 @@ const { cleanFileName } = require('~/server/utils/files');
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,9 +1,9 @@
const multer = require('multer');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const router = express.Router();
const upload = multer();

View File

@@ -1,19 +1,33 @@
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
createSafeUser,
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 +135,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 +193,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 +340,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 user = createSafeUser(req.user);
if (!user?.id) {
if (!user.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
@@ -320,7 +372,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
}
const result = await reinitMCPServer({
req,
user,
serverName,
userMCPAuthMap,
});

View File

@@ -3,8 +3,8 @@ const { logger } = require('@librechat/data-schemas');
const { ContentTypes } = require('librechat-data-provider');
const {
saveConvo,
saveMessage,
getMessage,
saveMessage,
getMessages,
updateMessage,
deleteMessages,
@@ -58,34 +58,51 @@ router.get('/', async (req, res) => {
const nextCursor = messages.length > pageSize ? messages.pop()[sortField] : null;
response = { messages, nextCursor };
} else if (search) {
const searchResults = await Message.meiliSearch(search, undefined, true);
const searchResults = await Message.meiliSearch(search, { filter: `user = "${user}"` }, true);
const messages = searchResults.hits || [];
const result = await getConvosQueried(req.user.id, messages, cursor);
const activeMessages = [];
const messageIds = [];
const cleanedMessages = [];
for (let i = 0; i < messages.length; i++) {
let message = messages[i];
if (message.conversationId.includes('--')) {
message.conversationId = cleanUpPrimaryKeyValue(message.conversationId);
}
if (result.convoMap[message.conversationId]) {
const convo = result.convoMap[message.conversationId];
const dbMessage = await getMessage({ user, messageId: message.messageId });
activeMessages.push({
...message,
title: convo.title,
conversationId: message.conversationId,
model: convo.model,
isCreatedByUser: dbMessage?.isCreatedByUser,
endpoint: dbMessage?.endpoint,
iconURL: dbMessage?.iconURL,
});
messageIds.push(message.messageId);
cleanedMessages.push(message);
}
}
const dbMessages = await getMessages({
user,
messageId: { $in: messageIds },
});
const dbMessageMap = {};
for (const dbMessage of dbMessages) {
dbMessageMap[dbMessage.messageId] = dbMessage;
}
const activeMessages = [];
for (const message of cleanedMessages) {
const convo = result.convoMap[message.conversationId];
const dbMessage = dbMessageMap[message.messageId];
activeMessages.push({
...message,
title: convo.title,
conversationId: message.conversationId,
model: convo.model,
isCreatedByUser: dbMessage?.isCreatedByUser,
endpoint: dbMessage?.endpoint,
iconURL: dbMessage?.iconURL,
});
}
response = { messages: activeMessages, nextCursor: null };
} else {
response = { messages: [], nextCursor: null };

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

@@ -1,8 +1,8 @@
const express = require('express');
const crypto = require('crypto');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { getPresets, savePreset, deletePresets } = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { logger } = require('~/config');
const router = express.Router();
router.use(requireJwtAuth);

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

@@ -1,7 +1,7 @@
const express = require('express');
const { MeiliSearch } = require('meilisearch');
const { isEnabled } = require('@librechat/api');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { isEnabled } = require('~/server/utils');
const router = express.Router();

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