mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 19:30:15 +01:00
🗝️ feat: User Provided Credentials for MCP Servers (#7980)
* 🗝️ feat: Per-User Credentials for MCP Servers
chore: add aider to gitignore
feat: fill custom variables to MCP server
feat: replace placeholders with custom user MCP variables
feat: handle MCP install/uninstall (uses pluginauths)
feat: add MCP custom variables dialog to MCPSelect
feat: add MCP custom variables dialog to the side panel
feat: do not require to fill MCP credentials for in tools dialog
feat: add translations keys (en+cs) for custom MCP variables
fix: handle LIBRECHAT_USER_ID correctly during MCP var replacement
style: remove unused MCP translation keys
style: fix eslint for MCP custom vars
chore: move aider gitignore to AI section
* feat: Add Plugin Authentication Methods to data-schemas
* refactor: Replace PluginAuth model methods with new utility functions for improved code organization and maintainability
* refactor: Move IPluginAuth interface to types directory for better organization and update pluginAuth schema to use the new import
* refactor: Remove unused getUsersPluginsAuthValuesMap function and streamline PluginService.js; add new getPluginAuthMap function for improved plugin authentication handling
* chore: fix typing for optional tools property with GenericTool[] type
* chore: update librechat-data-provider version to 0.7.88
* refactor: optimize getUserMCPAuthMap function by reducing variable usage and improving server key collection logic
* refactor: streamline MCP tool creation by removing customUserVars parameter and enhancing user-specific authentication handling to avoid closure encapsulation
* refactor: extract processSingleValue function to streamline MCP environment variable processing and enhance readability
* refactor: enhance MCP tool processing logic by simplifying conditions and improving authentication handling for custom user variables
* ci: fix action tests
* chore: fix imports, remove comments
* chore: remove non-english translations
* fix: remove newline at end of translation.json file
---------
Co-authored-by: Aleš Kůtek <kutekales@gmail.com>
This commit is contained in:
parent
8b15bb2ed6
commit
3e4b01de82
36 changed files with 1536 additions and 166 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.7.87",
|
||||
"version": "0.7.88",
|
||||
"description": "data services for librechat apps",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import axios from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import axios from 'axios';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { ParametersSchema } from '../src/actions';
|
||||
import type { FlowchartSchema } from './openapiSpecs';
|
||||
import {
|
||||
createURL,
|
||||
resolveRef,
|
||||
|
|
@ -15,9 +17,7 @@ import {
|
|||
scholarAIOpenapiSpec,
|
||||
swapidev,
|
||||
} from './openapiSpecs';
|
||||
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/assistants';
|
||||
import type { FlowchartSchema } from './openapiSpecs';
|
||||
import type { ParametersSchema } from '../src/actions';
|
||||
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/agents';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
|
|
|||
|
|
@ -525,5 +525,188 @@ describe('Environment Variable Extraction (MCP)', () => {
|
|||
const result3 = processMCPEnv(obj3, userWithBoth);
|
||||
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
|
||||
});
|
||||
|
||||
it('should process customUserVars in env field', () => {
|
||||
const user = createTestUser();
|
||||
const customUserVars = {
|
||||
CUSTOM_VAR_1: 'custom-value-1',
|
||||
CUSTOM_VAR_2: 'custom-value-2',
|
||||
};
|
||||
const obj: MCPOptions = {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {
|
||||
VAR_A: '{{CUSTOM_VAR_1}}',
|
||||
VAR_B: 'Value with {{CUSTOM_VAR_2}}',
|
||||
VAR_C: '${TEST_API_KEY}',
|
||||
VAR_D: '{{LIBRECHAT_USER_EMAIL}}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
|
||||
expect('env' in result && result.env).toEqual({
|
||||
VAR_A: 'custom-value-1',
|
||||
VAR_B: 'Value with custom-value-2',
|
||||
VAR_C: 'test-api-key-value',
|
||||
VAR_D: 'test@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process customUserVars in headers field', () => {
|
||||
const user = createTestUser();
|
||||
const customUserVars = {
|
||||
USER_TOKEN: 'user-specific-token',
|
||||
REGION: 'us-west-1',
|
||||
};
|
||||
const obj: MCPOptions = {
|
||||
type: 'sse',
|
||||
url: 'https://example.com/api',
|
||||
headers: {
|
||||
Authorization: 'Bearer {{USER_TOKEN}}',
|
||||
'X-Region': '{{REGION}}',
|
||||
'X-System-Key': '${TEST_API_KEY}',
|
||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
|
||||
expect('headers' in result && result.headers).toEqual({
|
||||
Authorization: 'Bearer user-specific-token',
|
||||
'X-Region': 'us-west-1',
|
||||
'X-System-Key': 'test-api-key-value',
|
||||
'X-User-Id': 'test-user-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process customUserVars in URL field', () => {
|
||||
const user = createTestUser();
|
||||
const customUserVars = {
|
||||
API_VERSION: 'v2',
|
||||
TENANT_ID: 'tenant123',
|
||||
};
|
||||
const obj: MCPOptions = {
|
||||
type: 'websocket',
|
||||
url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}',
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
|
||||
expect('url' in result && result.url).toBe(
|
||||
'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize customUserVars over user fields and system env vars if placeholders are the same (though not recommended)', () => {
|
||||
// This tests the order of operations: customUserVars -> userFields -> systemEnv
|
||||
// BUt it's generally not recommended to have overlapping placeholder names.
|
||||
process.env.LIBRECHAT_USER_EMAIL = 'system-email-should-be-overridden';
|
||||
const user = createTestUser({ email: 'user-email-should-be-overridden' });
|
||||
const customUserVars = {
|
||||
LIBRECHAT_USER_EMAIL: 'custom-email-wins',
|
||||
};
|
||||
const obj: MCPOptions = {
|
||||
type: 'sse',
|
||||
url: 'https://example.com/api',
|
||||
headers: {
|
||||
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}', // Placeholder that could match custom, user, or system
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins');
|
||||
|
||||
// Clean up env var
|
||||
delete process.env.LIBRECHAT_USER_EMAIL;
|
||||
});
|
||||
|
||||
it('should handle customUserVars with no matching placeholders', () => {
|
||||
const user = createTestUser();
|
||||
const customUserVars = {
|
||||
UNUSED_VAR: 'unused-value',
|
||||
};
|
||||
const obj: MCPOptions = {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {
|
||||
API_KEY: '${TEST_API_KEY}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
expect('env' in result && result.env).toEqual({
|
||||
API_KEY: 'test-api-key-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle placeholders with no matching customUserVars (falling back to user/system vars)', () => {
|
||||
const user = createTestUser({ email: 'user-provided-email@example.com' });
|
||||
// No customUserVars provided or customUserVars is empty
|
||||
const customUserVars = {};
|
||||
const obj: MCPOptions = {
|
||||
type: 'sse',
|
||||
url: 'https://example.com/api',
|
||||
headers: {
|
||||
'User-Email-Header': '{{LIBRECHAT_USER_EMAIL}}', // Should use user.email
|
||||
'System-Key-Header': '${TEST_API_KEY}', // Should use process.env.TEST_API_KEY
|
||||
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}', // Should remain as placeholder
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
expect('headers' in result && result.headers).toEqual({
|
||||
'User-Email-Header': 'user-provided-email@example.com',
|
||||
'System-Key-Header': 'test-api-key-value',
|
||||
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly process a mix of all variable types', () => {
|
||||
const user = createTestUser({ id: 'userXYZ', username: 'john.doe' });
|
||||
const customUserVars = {
|
||||
CUSTOM_ENDPOINT_ID: 'ep123',
|
||||
ANOTHER_CUSTOM: 'another_val',
|
||||
};
|
||||
|
||||
const obj = {
|
||||
type: 'streamable-http' as const,
|
||||
url: 'https://{{CUSTOM_ENDPOINT_ID}}.example.com/users/{{LIBRECHAT_USER_USERNAME}}',
|
||||
headers: {
|
||||
'X-Auth-Token': '{{CUSTOM_TOKEN_FROM_USER_SETTINGS}}', // Assuming this would be a custom var
|
||||
'X-User-ID': '{{LIBRECHAT_USER_ID}}',
|
||||
'X-System-Test-Key': '${TEST_API_KEY}', // Using existing env var from beforeEach
|
||||
},
|
||||
env: {
|
||||
PROCESS_MODE: '{{PROCESS_MODE_CUSTOM}}', // Another custom var
|
||||
USER_HOME_DIR: '/home/{{LIBRECHAT_USER_USERNAME}}',
|
||||
SYSTEM_PATH: '${PATH}', // Example of a system env var
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate customUserVars that would be passed, including those for headers and env
|
||||
const allCustomVarsForCall = {
|
||||
...customUserVars,
|
||||
CUSTOM_TOKEN_FROM_USER_SETTINGS: 'secretToken123!',
|
||||
PROCESS_MODE_CUSTOM: 'production',
|
||||
};
|
||||
|
||||
// Cast obj to MCPOptions when calling processMCPEnv.
|
||||
// This acknowledges the object might not strictly conform to one schema in the union,
|
||||
// but we are testing the function's ability to handle these properties if present.
|
||||
const result = processMCPEnv(obj as MCPOptions, user, allCustomVarsForCall);
|
||||
|
||||
expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe');
|
||||
expect('headers' in result && result.headers).toEqual({
|
||||
'X-Auth-Token': 'secretToken123!',
|
||||
'X-User-ID': 'userXYZ',
|
||||
'X-System-Test-Key': 'test-api-key-value', // Expecting value of TEST_API_KEY
|
||||
});
|
||||
expect('env' in result && result.env).toEqual({
|
||||
PROCESS_MODE: 'production',
|
||||
USER_HOME_DIR: '/home/john.doe',
|
||||
SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -588,6 +588,18 @@ export type TStartupConfig = {
|
|||
scraperType?: ScraperTypes;
|
||||
rerankerType?: RerankerTypes;
|
||||
};
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{
|
||||
customUserVars: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export enum OCRStrategy {
|
||||
|
|
@ -885,7 +897,6 @@ export const defaultModels = {
|
|||
[EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'],
|
||||
[EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels)
|
||||
[EModelEndpoint.google]: [
|
||||
// Shared Google Models between Vertex AI & Gen AI
|
||||
// Gemini 2.0 Models
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-flash-exp',
|
||||
|
|
@ -1395,6 +1406,8 @@ export enum Constants {
|
|||
GLOBAL_PROJECT_NAME = 'instance',
|
||||
/** Delimiter for MCP tools */
|
||||
mcp_delimiter = '_mcp_',
|
||||
/** Prefix for MCP plugins */
|
||||
mcp_prefix = 'mcp_',
|
||||
/** Placeholder Agent ID for Ephemeral Agents */
|
||||
EPHEMERAL_AGENT_ID = 'ephemeral',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,11 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => {
|
|||
|
||||
/* Config */
|
||||
|
||||
export const getStartupConfig = (): Promise<config.TStartupConfig> => {
|
||||
export const getStartupConfig = (): Promise<
|
||||
config.TStartupConfig & {
|
||||
mcpCustomUserVars?: Record<string, { title: string; description: string }>;
|
||||
}
|
||||
> => {
|
||||
return request.get(endpoints.config());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ const BaseOptionsSchema = z.object({
|
|||
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(),
|
||||
})
|
||||
.optional(),
|
||||
customUserVars: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const StdioOptionsSchema = BaseOptionsSchema.extend({
|
||||
|
|
@ -191,13 +200,55 @@ function processUserPlaceholders(value: string, user?: TUser): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function processSingleValue({
|
||||
originalValue,
|
||||
customUserVars,
|
||||
user,
|
||||
}: {
|
||||
originalValue: string;
|
||||
customUserVars?: Record<string, string>;
|
||||
user?: TUser;
|
||||
}): string {
|
||||
let value = originalValue;
|
||||
|
||||
// 1. Replace custom user variables
|
||||
if (customUserVars) {
|
||||
for (const [varName, varVal] of Object.entries(customUserVars)) {
|
||||
/** Escaped varName for use in regex to avoid issues with special characters */
|
||||
const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const placeholderRegex = new RegExp(`\\{\\{${escapedVarName}\\}\\}`, 'g');
|
||||
value = value.replace(placeholderRegex, varVal);
|
||||
}
|
||||
}
|
||||
|
||||
// 2.A. Special handling for LIBRECHAT_USER_ID placeholder
|
||||
// This ensures {{LIBRECHAT_USER_ID}} is replaced only if user.id is available.
|
||||
// If user.id is null/undefined, the placeholder remains
|
||||
if (user && user.id != null && value.includes('{{LIBRECHAT_USER_ID}}')) {
|
||||
value = value.replace(/\{\{LIBRECHAT_USER_ID\}\}/g, String(user.id));
|
||||
}
|
||||
|
||||
// 2.B. Replace other standard user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}})
|
||||
value = processUserPlaceholders(value, user);
|
||||
|
||||
// 3. Replace system environment variables
|
||||
value = extractEnvVariable(value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively processes an object to replace environment variables in string values
|
||||
* @param obj - The object to process
|
||||
* @param user - The user object containing all user fields
|
||||
* @param customUserVars - vars that user set in settings
|
||||
* @returns - The processed object with environment variables replaced
|
||||
*/
|
||||
export function processMCPEnv(obj: Readonly<MCPOptions>, user?: TUser): MCPOptions {
|
||||
export function processMCPEnv(
|
||||
obj: Readonly<MCPOptions>,
|
||||
user?: TUser,
|
||||
customUserVars?: Record<string, string>,
|
||||
): MCPOptions {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
|
@ -206,32 +257,25 @@ export function processMCPEnv(obj: Readonly<MCPOptions>, user?: TUser): MCPOptio
|
|||
|
||||
if ('env' in newObj && newObj.env) {
|
||||
const processedEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(newObj.env)) {
|
||||
let processedValue = extractEnvVariable(value);
|
||||
processedValue = processUserPlaceholders(processedValue, user);
|
||||
processedEnv[key] = processedValue;
|
||||
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
||||
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
|
||||
}
|
||||
newObj.env = processedEnv;
|
||||
} else if ('headers' in newObj && newObj.headers) {
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(newObj.headers)) {
|
||||
const userId = user?.id;
|
||||
if (value === '{{LIBRECHAT_USER_ID}}' && userId != null) {
|
||||
processedHeaders[key] = String(userId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let processedValue = extractEnvVariable(value);
|
||||
processedValue = processUserPlaceholders(processedValue, user);
|
||||
processedHeaders[key] = processedValue;
|
||||
// Process headers if they exist (for WebSocket, SSE, StreamableHTTP types)
|
||||
// Note: `env` and `headers` are on different branches of the MCPOptions union type.
|
||||
if ('headers' in newObj && newObj.headers) {
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, originalValue] of Object.entries(newObj.headers)) {
|
||||
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
|
||||
}
|
||||
newObj.headers = processedHeaders;
|
||||
}
|
||||
|
||||
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
|
||||
if ('url' in newObj && newObj.url) {
|
||||
let processedUrl = extractEnvVariable(newObj.url);
|
||||
processedUrl = processUserPlaceholders(processedUrl, user);
|
||||
newObj.url = processedUrl;
|
||||
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
|
||||
}
|
||||
|
||||
return newObj;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue