mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-16 23:48:09 +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
93
packages/api/src/agents/auth.ts
Normal file
93
packages/api/src/agents/auth.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { IPluginAuth, PluginAuthMethods } from '@librechat/data-schemas';
|
||||
import { decrypt } from '../crypto/encryption';
|
||||
|
||||
export interface GetPluginAuthMapParams {
|
||||
userId: string;
|
||||
pluginKeys: string[];
|
||||
throwError?: boolean;
|
||||
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
|
||||
}
|
||||
|
||||
export type PluginAuthMap = Record<string, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts authentication values for multiple plugins
|
||||
* @returns A map where keys are pluginKeys and values are objects of authField:decryptedValue pairs
|
||||
*/
|
||||
export async function getPluginAuthMap({
|
||||
userId,
|
||||
pluginKeys,
|
||||
throwError = true,
|
||||
findPluginAuthsByKeys,
|
||||
}: GetPluginAuthMapParams): Promise<PluginAuthMap> {
|
||||
try {
|
||||
/** Early return for empty plugin keys */
|
||||
if (!pluginKeys?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** All plugin auths for current user query */
|
||||
const pluginAuths = await findPluginAuthsByKeys({ userId, pluginKeys });
|
||||
|
||||
/** Group auth records by pluginKey for efficient lookup */
|
||||
const authsByPlugin = new Map<string, IPluginAuth[]>();
|
||||
for (const auth of pluginAuths) {
|
||||
if (!auth.pluginKey) {
|
||||
logger.warn(`[getPluginAuthMap] Missing pluginKey for userId ${userId}`);
|
||||
continue;
|
||||
}
|
||||
const existing = authsByPlugin.get(auth.pluginKey) || [];
|
||||
existing.push(auth);
|
||||
authsByPlugin.set(auth.pluginKey, existing);
|
||||
}
|
||||
|
||||
const authMap: PluginAuthMap = {};
|
||||
const decryptionPromises: Promise<void>[] = [];
|
||||
|
||||
/** Single loop through requested pluginKeys */
|
||||
for (const pluginKey of pluginKeys) {
|
||||
authMap[pluginKey] = {};
|
||||
const auths = authsByPlugin.get(pluginKey) || [];
|
||||
|
||||
for (const auth of auths) {
|
||||
decryptionPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const decryptedValue = await decrypt(auth.value);
|
||||
authMap[pluginKey][auth.authField] = decryptedValue;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(
|
||||
`[getPluginAuthMap] Decryption failed for userId ${userId}, plugin ${pluginKey}, field ${auth.authField}: ${message}`,
|
||||
);
|
||||
|
||||
if (throwError) {
|
||||
throw new Error(
|
||||
`Decryption failed for plugin ${pluginKey}, field ${auth.authField}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(decryptionPromises);
|
||||
return authMap;
|
||||
} catch (error) {
|
||||
if (!throwError) {
|
||||
/** Empty objects for each plugin key on error */
|
||||
return pluginKeys.reduce((acc, key) => {
|
||||
acc[key] = {};
|
||||
return acc;
|
||||
}, {} as PluginAuthMap);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(
|
||||
`[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import { Run, Providers } from '@librechat/agents';
|
||||
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
|
||||
import type { StandardGraphConfig, EventHandler, GraphEvents, IState } from '@librechat/agents';
|
||||
import type {
|
||||
StandardGraphConfig,
|
||||
EventHandler,
|
||||
GenericTool,
|
||||
GraphEvents,
|
||||
IState,
|
||||
} from '@librechat/agents';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type * as t from '~/types';
|
||||
|
||||
|
|
@ -32,7 +38,7 @@ export async function createRun({
|
|||
streaming = true,
|
||||
streamUsage = true,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: Omit<Agent, 'tools'> & { tools?: GenericTool[] };
|
||||
signal: AbortSignal;
|
||||
runId?: string;
|
||||
streaming?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* MCP */
|
||||
export * from './mcp/manager';
|
||||
export * from './mcp/oauth';
|
||||
export * from './mcp/auth';
|
||||
/* Utilities */
|
||||
export * from './mcp/utils';
|
||||
export * from './utils';
|
||||
|
|
|
|||
58
packages/api/src/mcp/auth.ts
Normal file
58
packages/api/src/mcp/auth.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { PluginAuthMethods } from '@librechat/data-schemas';
|
||||
import type { GenericTool } from '@librechat/agents';
|
||||
import { getPluginAuthMap } from '~/agents/auth';
|
||||
import { mcpToolPattern } from './utils';
|
||||
|
||||
export async function getUserMCPAuthMap({
|
||||
userId,
|
||||
tools,
|
||||
appTools,
|
||||
findPluginAuthsByKeys,
|
||||
}: {
|
||||
userId: string;
|
||||
tools: GenericTool[] | undefined;
|
||||
appTools: Record<string, unknown>;
|
||||
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
|
||||
}) {
|
||||
if (!tools || tools.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const uniqueMcpServers = new Set<string>();
|
||||
|
||||
for (const tool of tools) {
|
||||
const toolKey = tool.name;
|
||||
if (toolKey && appTools[toolKey] && mcpToolPattern.test(toolKey)) {
|
||||
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
uniqueMcpServers.add(`${Constants.mcp_prefix}${serverName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueMcpServers.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mcpPluginKeysToFetch = Array.from(uniqueMcpServers);
|
||||
|
||||
let allMcpCustomUserVars: Record<string, Record<string, string>> = {};
|
||||
try {
|
||||
allMcpCustomUserVars = await getPluginAuthMap({
|
||||
userId,
|
||||
pluginKeys: mcpPluginKeysToFetch,
|
||||
throwError: false,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[handleTools] Error batch fetching customUserVars for MCP tools (keys: ${mcpPluginKeysToFetch.join(
|
||||
', ',
|
||||
)}), user ${userId}: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
return allMcpCustomUserVars;
|
||||
}
|
||||
|
|
@ -14,10 +14,6 @@ import { MCPTokenStorage } from './oauth/tokens';
|
|||
import { formatToolContent } from './parsers';
|
||||
import { MCPConnection } from './connection';
|
||||
|
||||
export interface CallToolOptions extends RequestOptions {
|
||||
user?: TUser;
|
||||
}
|
||||
|
||||
export class MCPManager {
|
||||
private static instance: MCPManager | null = null;
|
||||
/** App-level connections initialized at startup */
|
||||
|
|
@ -28,7 +24,11 @@ export class MCPManager {
|
|||
private userLastActivity: Map<string, number> = new Map();
|
||||
private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable)
|
||||
private mcpConfigs: t.MCPServers = {};
|
||||
private processMCPEnv?: (obj: MCPOptions, user?: TUser) => MCPOptions; // Store the processing function
|
||||
private processMCPEnv?: (
|
||||
obj: MCPOptions,
|
||||
user?: TUser,
|
||||
customUserVars?: Record<string, string>,
|
||||
) => MCPOptions; // Store the processing function
|
||||
/** Store MCP server instructions */
|
||||
private serverInstructions: Map<string, string> = new Map();
|
||||
|
||||
|
|
@ -63,7 +63,6 @@ export class MCPManager {
|
|||
if (!tokenMethods) {
|
||||
logger.info('[MCP] No token methods provided, token persistence will not be available');
|
||||
}
|
||||
|
||||
const entries = Object.entries(mcpServers);
|
||||
const initializedServers = new Set();
|
||||
const connectionResults = await Promise.allSettled(
|
||||
|
|
@ -382,6 +381,7 @@ export class MCPManager {
|
|||
user,
|
||||
serverName,
|
||||
flowManager,
|
||||
customUserVars,
|
||||
tokenMethods,
|
||||
oauthStart,
|
||||
oauthEnd,
|
||||
|
|
@ -390,6 +390,7 @@ export class MCPManager {
|
|||
user: TUser;
|
||||
serverName: string;
|
||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||
customUserVars?: Record<string, string>;
|
||||
tokenMethods?: TokenMethods;
|
||||
oauthStart?: (authURL: string) => Promise<void>;
|
||||
oauthEnd?: () => Promise<void>;
|
||||
|
|
@ -444,9 +445,8 @@ export class MCPManager {
|
|||
}
|
||||
|
||||
if (this.processMCPEnv) {
|
||||
config = { ...(this.processMCPEnv(config, user) ?? {}) };
|
||||
config = { ...(this.processMCPEnv(config, user, customUserVars) ?? {}) };
|
||||
}
|
||||
|
||||
/** If no in-memory tokens, tokens from persistent storage */
|
||||
let tokens: MCPOAuthTokens | null = null;
|
||||
if (tokenMethods?.findToken) {
|
||||
|
|
@ -752,7 +752,6 @@ export class MCPManager {
|
|||
getServerTools?: (serverName: string) => Promise<t.LCManifestTool[] | undefined>;
|
||||
}): Promise<t.LCToolManifest> {
|
||||
const mcpTools: t.LCManifestTool[] = [];
|
||||
|
||||
for (const [serverName, connection] of this.connections.entries()) {
|
||||
try {
|
||||
/** Attempt to ensure connection is active, with reconnection if needed */
|
||||
|
|
@ -784,13 +783,21 @@ export class MCPManager {
|
|||
const serverTools: t.LCManifestTool[] = [];
|
||||
for (const tool of tools) {
|
||||
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||
|
||||
const config = this.mcpConfigs[serverName];
|
||||
const manifestTool: t.LCManifestTool = {
|
||||
name: tool.name,
|
||||
pluginKey,
|
||||
description: tool.description ?? '',
|
||||
icon: connection.iconPath,
|
||||
authConfig: config?.customUserVars
|
||||
? Object.entries(config.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
const config = this.mcpConfigs[serverName];
|
||||
if (config?.chatMenu === false) {
|
||||
manifestTool.chatMenu = false;
|
||||
}
|
||||
|
|
@ -814,6 +821,7 @@ export class MCPManager {
|
|||
* for user-specific connections upon successful call initiation.
|
||||
*/
|
||||
async callTool({
|
||||
user,
|
||||
serverName,
|
||||
toolName,
|
||||
provider,
|
||||
|
|
@ -823,20 +831,22 @@ export class MCPManager {
|
|||
flowManager,
|
||||
oauthStart,
|
||||
oauthEnd,
|
||||
customUserVars,
|
||||
}: {
|
||||
user?: TUser;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
provider: t.Provider;
|
||||
toolArguments?: Record<string, unknown>;
|
||||
options?: CallToolOptions;
|
||||
options?: RequestOptions;
|
||||
tokenMethods?: TokenMethods;
|
||||
customUserVars?: Record<string, string>;
|
||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||
oauthStart?: (authURL: string) => Promise<void>;
|
||||
oauthEnd?: () => Promise<void>;
|
||||
}): Promise<t.FormattedToolResponse> {
|
||||
/** User-specific connection */
|
||||
let connection: MCPConnection | undefined;
|
||||
const { user, ...callOptions } = options ?? {};
|
||||
const userId = user?.id;
|
||||
const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`;
|
||||
|
||||
|
|
@ -852,6 +862,7 @@ export class MCPManager {
|
|||
oauthStart,
|
||||
oauthEnd,
|
||||
signal: options?.signal,
|
||||
customUserVars,
|
||||
});
|
||||
} else {
|
||||
/** App-level connection */
|
||||
|
|
@ -883,7 +894,7 @@ export class MCPManager {
|
|||
CallToolResultSchema,
|
||||
{
|
||||
timeout: connection.timeout,
|
||||
...callOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
if (userId) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
|||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
||||
export type StreamableHTTPOptions = z.infer<typeof StreamableHTTPOptionsSchema>;
|
||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema> & {
|
||||
customUserVars?: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
export type MCPServers = z.infer<typeof MCPServersSchema>;
|
||||
export interface MCPResource {
|
||||
uri: string;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { Constants } from 'librechat-data-provider';
|
||||
|
||||
export const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
|
||||
/**
|
||||
* Normalizes a server name to match the pattern ^[a-zA-Z0-9_.-]+$
|
||||
* This is required for Azure OpenAI models with Tool Calling
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createRoleMethods, type RoleMethods } from './role';
|
|||
/* Memories */
|
||||
import { createMemoryMethods, type MemoryMethods } from './memory';
|
||||
import { createShareMethods, type ShareMethods } from './share';
|
||||
import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
|
||||
|
||||
/**
|
||||
* Creates all database methods for all collections
|
||||
|
|
@ -17,13 +18,15 @@ export function createMethods(mongoose: typeof import('mongoose')) {
|
|||
...createRoleMethods(mongoose),
|
||||
...createMemoryMethods(mongoose),
|
||||
...createShareMethods(mongoose),
|
||||
...createPluginAuthMethods(mongoose),
|
||||
};
|
||||
}
|
||||
|
||||
export type { MemoryMethods, ShareMethods, TokenMethods };
|
||||
export type { MemoryMethods, ShareMethods, TokenMethods, PluginAuthMethods };
|
||||
export type AllMethods = UserMethods &
|
||||
SessionMethods &
|
||||
TokenMethods &
|
||||
RoleMethods &
|
||||
MemoryMethods &
|
||||
ShareMethods;
|
||||
ShareMethods &
|
||||
PluginAuthMethods;
|
||||
|
|
|
|||
140
packages/data-schemas/src/methods/pluginAuth.ts
Normal file
140
packages/data-schemas/src/methods/pluginAuth.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import type { DeleteResult, Model } from 'mongoose';
|
||||
import type { IPluginAuth } from '~/schema/pluginAuth';
|
||||
import type {
|
||||
FindPluginAuthsByKeysParams,
|
||||
UpdatePluginAuthParams,
|
||||
DeletePluginAuthParams,
|
||||
FindPluginAuthParams,
|
||||
} from '~/types';
|
||||
|
||||
// Factory function that takes mongoose instance and returns the methods
|
||||
export function createPluginAuthMethods(mongoose: typeof import('mongoose')) {
|
||||
const PluginAuth: Model<IPluginAuth> = mongoose.models.PluginAuth;
|
||||
|
||||
/**
|
||||
* Finds a single plugin auth entry by userId and authField
|
||||
*/
|
||||
async function findOnePluginAuth({
|
||||
userId,
|
||||
authField,
|
||||
}: FindPluginAuthParams): Promise<IPluginAuth | null> {
|
||||
try {
|
||||
return await PluginAuth.findOne({ userId, authField }).lean();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to find plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds multiple plugin auth entries by userId and pluginKeys
|
||||
*/
|
||||
async function findPluginAuthsByKeys({
|
||||
userId,
|
||||
pluginKeys,
|
||||
}: FindPluginAuthsByKeysParams): Promise<IPluginAuth[]> {
|
||||
try {
|
||||
if (!pluginKeys || pluginKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await PluginAuth.find({
|
||||
userId,
|
||||
pluginKey: { $in: pluginKeys },
|
||||
}).lean();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to find plugin auths: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates a plugin auth entry
|
||||
*/
|
||||
async function updatePluginAuth({
|
||||
userId,
|
||||
authField,
|
||||
pluginKey,
|
||||
value,
|
||||
}: UpdatePluginAuthParams): Promise<IPluginAuth> {
|
||||
try {
|
||||
const existingAuth = await PluginAuth.findOne({ userId, pluginKey, authField }).lean();
|
||||
|
||||
if (existingAuth) {
|
||||
return await PluginAuth.findOneAndUpdate(
|
||||
{ userId, pluginKey, authField },
|
||||
{ $set: { value } },
|
||||
{ new: true, upsert: true },
|
||||
).lean();
|
||||
} else {
|
||||
const newPluginAuth = await new PluginAuth({
|
||||
userId,
|
||||
authField,
|
||||
value,
|
||||
pluginKey,
|
||||
});
|
||||
await newPluginAuth.save();
|
||||
return newPluginAuth.toObject();
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to update plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes plugin auth entries based on provided parameters
|
||||
*/
|
||||
async function deletePluginAuth({
|
||||
userId,
|
||||
authField,
|
||||
pluginKey,
|
||||
all = false,
|
||||
}: DeletePluginAuthParams): Promise<DeleteResult> {
|
||||
try {
|
||||
if (all) {
|
||||
const filter: DeletePluginAuthParams = { userId };
|
||||
if (pluginKey) {
|
||||
filter.pluginKey = pluginKey;
|
||||
}
|
||||
return await PluginAuth.deleteMany(filter);
|
||||
}
|
||||
|
||||
if (!authField) {
|
||||
throw new Error('authField is required when all is false');
|
||||
}
|
||||
|
||||
return await PluginAuth.deleteOne({ userId, authField });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all plugin auth entries for a user
|
||||
*/
|
||||
async function deleteAllUserPluginAuths(userId: string): Promise<DeleteResult> {
|
||||
try {
|
||||
return await PluginAuth.deleteMany({ userId });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete all user plugin auths: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
findOnePluginAuth,
|
||||
findPluginAuthsByKeys,
|
||||
updatePluginAuth,
|
||||
deletePluginAuth,
|
||||
deleteAllUserPluginAuths,
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginAuthMethods = ReturnType<typeof createPluginAuthMethods>;
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
import { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IPluginAuth extends Document {
|
||||
authField: string;
|
||||
value: string;
|
||||
userId: string;
|
||||
pluginKey?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
import { Schema } from 'mongoose';
|
||||
import type { IPluginAuth } from '~/types';
|
||||
|
||||
const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ export * from './action';
|
|||
export * from './assistant';
|
||||
export * from './file';
|
||||
export * from './share';
|
||||
export * from './pluginAuth';
|
||||
/* Memories */
|
||||
export * from './memory';
|
||||
|
|
|
|||
40
packages/data-schemas/src/types/pluginAuth.ts
Normal file
40
packages/data-schemas/src/types/pluginAuth.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Document } from 'mongoose';
|
||||
|
||||
export interface IPluginAuth extends Document {
|
||||
authField: string;
|
||||
value: string;
|
||||
userId: string;
|
||||
pluginKey?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface PluginAuthQuery {
|
||||
userId: string;
|
||||
authField?: string;
|
||||
pluginKey?: string;
|
||||
}
|
||||
|
||||
export interface FindPluginAuthParams {
|
||||
userId: string;
|
||||
authField: string;
|
||||
}
|
||||
|
||||
export interface FindPluginAuthsByKeysParams {
|
||||
userId: string;
|
||||
pluginKeys: string[];
|
||||
}
|
||||
|
||||
export interface UpdatePluginAuthParams {
|
||||
userId: string;
|
||||
authField: string;
|
||||
pluginKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DeletePluginAuthParams {
|
||||
userId: string;
|
||||
authField?: string;
|
||||
pluginKey?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue