mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🏷️ feat: Request Placeholders for Custom Endpoint & MCP Headers (#9095)
* feat: Add conversation ID support to custom endpoint headers
- Add LIBRECHAT_CONVERSATION_ID to customUserVars when provided
- Pass conversation ID to header resolution for dynamic headers
- Add comprehensive test coverage
Enables custom endpoints to access conversation context using {{LIBRECHAT_CONVERSATION_ID}} placeholder.
* fix: filter out unresolved placeholders from headers (thanks @MrunmayS)
* feat: add support for request body placeholders in custom endpoint headers
- Add {{LIBRECHAT_BODY_*}} placeholders for conversationId, parentMessageId, messageId
- Update tests to reflect new body placeholder functionality
* refactor resolveHeaders
* style: minor styling cleanup
* fix: type error in unit test
* feat: add body to other endpoints
* feat: add body for mcp tool calls
* chore: remove changes that unnecessarily increase scope after clarification of requirements
* refactor: move http.ts to packages/api and have RequestBody intersect with Express request body
* refactor: processMCPEnv now uses single object argument pattern
* refactor: update processMCPEnv to use 'options' parameter and align types across MCP connection classes
* feat: enhance MCP connection handling with dynamic request headers to pass request body fields
---------
Co-authored-by: Gopal Sharma <gopalsharma@gopal.sharma1>
Co-authored-by: s10gopal <36487439+s10gopal@users.noreply.github.com>
Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
This commit is contained in:
parent
627f0bffe5
commit
d7d02766ea
25 changed files with 353 additions and 171 deletions
|
|
@ -37,6 +37,8 @@ class BaseClient {
|
||||||
this.conversationId;
|
this.conversationId;
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.responseMessageId;
|
this.responseMessageId;
|
||||||
|
/** @type {string} */
|
||||||
|
this.parentMessageId;
|
||||||
/** @type {TAttachment[]} */
|
/** @type {TAttachment[]} */
|
||||||
this.attachments;
|
this.attachments;
|
||||||
/** The key for the usage object's input tokens
|
/** The key for the usage object's input tokens
|
||||||
|
|
@ -614,15 +616,19 @@ class BaseClient {
|
||||||
this.currentMessages.push(userMessage);
|
this.currentMessages.push(userMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
|
||||||
|
* this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
|
||||||
|
*/
|
||||||
|
const parentMessageId = isEdited ? head : userMessage.messageId;
|
||||||
|
this.parentMessageId = parentMessageId;
|
||||||
let {
|
let {
|
||||||
prompt: payload,
|
prompt: payload,
|
||||||
tokenCountMap,
|
tokenCountMap,
|
||||||
promptTokens,
|
promptTokens,
|
||||||
} = await this.buildMessages(
|
} = await this.buildMessages(
|
||||||
this.currentMessages,
|
this.currentMessages,
|
||||||
// When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
|
parentMessageId,
|
||||||
// this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
|
|
||||||
isEdited ? head : userMessage.messageId,
|
|
||||||
this.getBuildMessagesOptions(opts),
|
this.getBuildMessagesOptions(opts),
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -653,8 +653,10 @@ class OpenAIClient extends BaseClient {
|
||||||
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
||||||
configOptions.baseOptions = {
|
configOptions.baseOptions = {
|
||||||
headers: resolveHeaders({
|
headers: resolveHeaders({
|
||||||
...headers,
|
headers: {
|
||||||
...configOptions?.baseOptions?.headers,
|
...headers,
|
||||||
|
...configOptions?.baseOptions?.headers,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -749,7 +751,7 @@ class OpenAIClient extends BaseClient {
|
||||||
groupMap,
|
groupMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.options.headers = resolveHeaders(headers);
|
this.options.headers = resolveHeaders({ headers });
|
||||||
this.options.reverseProxyUrl = baseURL ?? null;
|
this.options.reverseProxyUrl = baseURL ?? null;
|
||||||
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
|
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
|
||||||
this.apiKey = azureOptions.azureOpenAIApiKey;
|
this.apiKey = azureOptions.azureOpenAIApiKey;
|
||||||
|
|
@ -1181,7 +1183,7 @@ ${convo}
|
||||||
modelGroupMap,
|
modelGroupMap,
|
||||||
groupMap,
|
groupMap,
|
||||||
});
|
});
|
||||||
opts.defaultHeaders = resolveHeaders(headers);
|
opts.defaultHeaders = resolveHeaders({ headers });
|
||||||
this.langchainProxy = extractBaseURL(baseURL);
|
this.langchainProxy = extractBaseURL(baseURL);
|
||||||
this.apiKey = azureOptions.azureOpenAIApiKey;
|
this.apiKey = azureOptions.azureOpenAIApiKey;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
// WeakMap to hold temporary data associated with requests
|
/** WeakMap to hold temporary data associated with requests */
|
||||||
const requestDataMap = new WeakMap();
|
const requestDataMap = new WeakMap();
|
||||||
|
|
||||||
const FinalizationRegistry = global.FinalizationRegistry || null;
|
const FinalizationRegistry = global.FinalizationRegistry || null;
|
||||||
|
|
@ -23,7 +23,7 @@ const clientRegistry = FinalizationRegistry
|
||||||
} else {
|
} else {
|
||||||
logger.debug('[FinalizationRegistry] Cleaning up client');
|
logger.debug('[FinalizationRegistry] Cleaning up client');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -55,6 +55,9 @@ function disposeClient(client) {
|
||||||
if (client.responseMessageId) {
|
if (client.responseMessageId) {
|
||||||
client.responseMessageId = null;
|
client.responseMessageId = null;
|
||||||
}
|
}
|
||||||
|
if (client.parentMessageId) {
|
||||||
|
client.parentMessageId = null;
|
||||||
|
}
|
||||||
if (client.message_file_map) {
|
if (client.message_file_map) {
|
||||||
client.message_file_map = null;
|
client.message_file_map = null;
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +337,7 @@ function disposeClient(client) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
client.options = null;
|
client.options = null;
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore errors during disposal
|
// Ignore errors during disposal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -768,6 +768,11 @@ class AgentClient extends BaseClient {
|
||||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||||
user_id: this.user ?? this.options.req.user?.id,
|
user_id: this.user ?? this.options.req.user?.id,
|
||||||
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
||||||
|
requestBody: {
|
||||||
|
messageId: this.responseMessageId,
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
parentMessageId: this.parentMessageId,
|
||||||
|
},
|
||||||
user: this.options.req.user,
|
user: this.options.req.user,
|
||||||
},
|
},
|
||||||
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
||||||
|
|
|
||||||
|
|
@ -109,14 +109,14 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
|
||||||
|
|
||||||
apiKey = azureOptions.azureOpenAIApiKey;
|
apiKey = azureOptions.azureOpenAIApiKey;
|
||||||
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
|
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
|
||||||
opts.defaultHeaders = resolveHeaders(
|
opts.defaultHeaders = resolveHeaders({
|
||||||
{
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'api-key': apiKey,
|
'api-key': apiKey,
|
||||||
'OpenAI-Beta': `assistants=${version}`,
|
'OpenAI-Beta': `assistants=${version}`,
|
||||||
},
|
},
|
||||||
req.user,
|
user: req.user,
|
||||||
);
|
});
|
||||||
opts.model = azureOptions.azureOpenAIApiDeploymentName;
|
opts.model = azureOptions.azureOpenAIApiDeploymentName;
|
||||||
|
|
||||||
if (initAppClient) {
|
if (initAppClient) {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
||||||
|
|
||||||
let resolvedHeaders = resolveHeaders(endpointConfig.headers, req.user);
|
let resolvedHeaders = resolveHeaders({
|
||||||
|
headers: endpointConfig.headers,
|
||||||
|
user: req.user,
|
||||||
|
body: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,14 @@ describe('custom/initializeClient', () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls resolveHeaders with headers and user', async () => {
|
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
|
||||||
const { resolveHeaders } = require('@librechat/api');
|
const { resolveHeaders } = require('@librechat/api');
|
||||||
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
||||||
expect(resolveHeaders).toHaveBeenCalledWith(
|
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||||
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
||||||
{ id: 'user-123', email: 'test@example.com' },
|
user: { id: 'user-123', email: 'test@example.com' },
|
||||||
);
|
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if endpoint config is missing', async () => {
|
it('throws if endpoint config is missing', async () => {
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,10 @@ const initializeClient = async ({
|
||||||
serverless = _serverless;
|
serverless = _serverless;
|
||||||
|
|
||||||
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
|
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
|
||||||
clientOptions.headers = resolveHeaders(
|
clientOptions.headers = resolveHeaders({
|
||||||
{ ...headers, ...(clientOptions.headers ?? {}) },
|
headers: { ...headers, ...(clientOptions.headers ?? {}) },
|
||||||
req.user,
|
user: req.user,
|
||||||
);
|
});
|
||||||
|
|
||||||
clientOptions.titleConvo = azureConfig.titleConvo;
|
clientOptions.titleConvo = azureConfig.titleConvo;
|
||||||
clientOptions.titleModel = azureConfig.titleModel;
|
clientOptions.titleModel = azureConfig.titleModel;
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
signal: derivedSignal,
|
signal: derivedSignal,
|
||||||
},
|
},
|
||||||
user: config?.configurable?.user,
|
user: config?.configurable?.user,
|
||||||
|
requestBody: config?.configurable?.requestBody,
|
||||||
customUserVars,
|
customUserVars,
|
||||||
flowManager,
|
flowManager,
|
||||||
tokenMethods: {
|
tokenMethods: {
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,8 @@ endpoints:
|
||||||
# recommended environment variables:
|
# recommended environment variables:
|
||||||
apiKey: '${OPENROUTER_KEY}'
|
apiKey: '${OPENROUTER_KEY}'
|
||||||
baseURL: 'https://openrouter.ai/api/v1'
|
baseURL: 'https://openrouter.ai/api/v1'
|
||||||
|
headers:
|
||||||
|
x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
|
||||||
models:
|
models:
|
||||||
default: ['meta-llama/llama-3-70b-instruct']
|
default: ['meta-llama/llama-3-70b-instruct']
|
||||||
fetch: true
|
fetch: true
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,10 @@ export const initializeOpenAI = async ({
|
||||||
});
|
});
|
||||||
|
|
||||||
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
|
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
|
||||||
clientOptions.headers = resolveHeaders(
|
clientOptions.headers = resolveHeaders({
|
||||||
{ ...headers, ...(clientOptions.headers ?? {}) },
|
headers: { ...headers, ...(clientOptions.headers ?? {}) },
|
||||||
req.user,
|
user: req.user,
|
||||||
);
|
});
|
||||||
|
|
||||||
const groupName = modelGroupMap[modelName || '']?.group;
|
const groupName = modelGroupMap[modelName || '']?.group;
|
||||||
if (groupName && groupMap[groupName]) {
|
if (groupName && groupMap[groupName]) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { logger } from '@librechat/data-schemas';
|
import { logger } from '@librechat/data-schemas';
|
||||||
import { MCPConnectionFactory, OAuthConnectionOptions } from '~/mcp/MCPConnectionFactory';
|
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
import type * as t from './types';
|
import type * as t from './types';
|
||||||
|
|
||||||
|
|
@ -10,9 +10,9 @@ import type * as t from './types';
|
||||||
export class ConnectionsRepository {
|
export class ConnectionsRepository {
|
||||||
protected readonly serverConfigs: Record<string, t.MCPOptions>;
|
protected readonly serverConfigs: Record<string, t.MCPOptions>;
|
||||||
protected connections: Map<string, MCPConnection> = new Map();
|
protected connections: Map<string, MCPConnection> = new Map();
|
||||||
protected oauthOpts: OAuthConnectionOptions | undefined;
|
protected oauthOpts: t.OAuthConnectionOptions | undefined;
|
||||||
|
|
||||||
constructor(serverConfigs: t.MCPServers, oauthOpts?: OAuthConnectionOptions) {
|
constructor(serverConfigs: t.MCPServers, oauthOpts?: t.OAuthConnectionOptions) {
|
||||||
this.serverConfigs = serverConfigs;
|
this.serverConfigs = serverConfigs;
|
||||||
this.oauthOpts = oauthOpts;
|
this.oauthOpts = oauthOpts;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { logger } from '@librechat/data-schemas';
|
import { logger } from '@librechat/data-schemas';
|
||||||
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
import type { TokenMethods } from '@librechat/data-schemas';
|
import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
|
||||||
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from '~/mcp/oauth';
|
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from '~/mcp/oauth';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
import type { FlowMetadata } from '~/flow/types';
|
import type { FlowMetadata } from '~/flow/types';
|
||||||
|
|
@ -10,23 +9,6 @@ import { MCPTokenStorage, MCPOAuthHandler } from '~/mcp/oauth';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
import { processMCPEnv } from '~/utils';
|
import { processMCPEnv } from '~/utils';
|
||||||
|
|
||||||
export interface BasicConnectionOptions {
|
|
||||||
serverName: string;
|
|
||||||
serverConfig: t.MCPOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OAuthConnectionOptions {
|
|
||||||
useOAuth: true;
|
|
||||||
user: TUser;
|
|
||||||
customUserVars?: Record<string, string>;
|
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
|
||||||
tokenMethods?: TokenMethods;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
oauthStart?: (authURL: string) => Promise<void>;
|
|
||||||
oauthEnd?: () => Promise<void>;
|
|
||||||
returnOnOAuth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory for creating MCP connections with optional OAuth authentication.
|
* Factory for creating MCP connections with optional OAuth authentication.
|
||||||
* Handles OAuth flows, token management, and connection retry logic.
|
* Handles OAuth flows, token management, and connection retry logic.
|
||||||
|
|
@ -49,15 +31,20 @@ export class MCPConnectionFactory {
|
||||||
|
|
||||||
/** Creates a new MCP connection with optional OAuth support */
|
/** Creates a new MCP connection with optional OAuth support */
|
||||||
static async create(
|
static async create(
|
||||||
basic: BasicConnectionOptions,
|
basic: t.BasicConnectionOptions,
|
||||||
oauth?: OAuthConnectionOptions,
|
oauth?: t.OAuthConnectionOptions,
|
||||||
): Promise<MCPConnection> {
|
): Promise<MCPConnection> {
|
||||||
const factory = new this(basic, oauth);
|
const factory = new this(basic, oauth);
|
||||||
return factory.createConnection();
|
return factory.createConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected constructor(basic: BasicConnectionOptions, oauth?: OAuthConnectionOptions) {
|
protected constructor(basic: t.BasicConnectionOptions, oauth?: t.OAuthConnectionOptions) {
|
||||||
this.serverConfig = processMCPEnv(basic.serverConfig, oauth?.user, oauth?.customUserVars);
|
this.serverConfig = processMCPEnv({
|
||||||
|
options: basic.serverConfig,
|
||||||
|
user: oauth?.user,
|
||||||
|
customUserVars: oauth?.customUserVars,
|
||||||
|
body: oauth?.requestBody,
|
||||||
|
});
|
||||||
this.serverName = basic.serverName;
|
this.serverName = basic.serverName;
|
||||||
this.useOAuth = !!oauth?.useOAuth;
|
this.useOAuth = !!oauth?.useOAuth;
|
||||||
this.logPrefix = oauth?.user
|
this.logPrefix = oauth?.user
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
import type { TUser } from 'librechat-data-provider';
|
||||||
import type { MCPOAuthTokens } from '~/mcp/oauth';
|
import type { MCPOAuthTokens } from '~/mcp/oauth';
|
||||||
|
import type { RequestBody } from '~/types';
|
||||||
import type * as t from './types';
|
import type * as t from './types';
|
||||||
import { UserConnectionManager } from '~/mcp/UserConnectionManager';
|
import { UserConnectionManager } from '~/mcp/UserConnectionManager';
|
||||||
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
||||||
import { formatToolContent } from './parsers';
|
import { formatToolContent } from './parsers';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
|
import { processMCPEnv } from '~/utils/env';
|
||||||
import { CONSTANTS } from './enum';
|
import { CONSTANTS } from './enum';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,6 +181,7 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
toolArguments,
|
toolArguments,
|
||||||
options,
|
options,
|
||||||
tokenMethods,
|
tokenMethods,
|
||||||
|
requestBody,
|
||||||
flowManager,
|
flowManager,
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
|
|
@ -190,6 +193,7 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
provider: t.Provider;
|
provider: t.Provider;
|
||||||
toolArguments?: Record<string, unknown>;
|
toolArguments?: Record<string, unknown>;
|
||||||
options?: RequestOptions;
|
options?: RequestOptions;
|
||||||
|
requestBody?: RequestBody;
|
||||||
tokenMethods?: TokenMethods;
|
tokenMethods?: TokenMethods;
|
||||||
customUserVars?: Record<string, string>;
|
customUserVars?: Record<string, string>;
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||||
|
|
@ -214,6 +218,7 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
customUserVars,
|
customUserVars,
|
||||||
|
requestBody,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
/** App-level connection */
|
/** App-level connection */
|
||||||
|
|
@ -234,6 +239,17 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawConfig = this.getRawConfig(serverName) as t.MCPOptions;
|
||||||
|
const currentOptions = processMCPEnv({
|
||||||
|
user,
|
||||||
|
options: rawConfig,
|
||||||
|
customUserVars: customUserVars,
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
if ('headers' in currentOptions) {
|
||||||
|
connection.setRequestHeaders(currentOptions.headers || {});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await connection.client.request(
|
const result = await connection.client.request(
|
||||||
{
|
{
|
||||||
method: 'tools/call',
|
method: 'tools/call',
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export class MCPServersRegistry {
|
||||||
|
|
||||||
constructor(configs: t.MCPServers) {
|
constructor(configs: t.MCPServers) {
|
||||||
this.rawConfigs = configs;
|
this.rawConfigs = configs;
|
||||||
this.parsedConfigs = mapValues(configs, (con) => processMCPEnv(con));
|
this.parsedConfigs = mapValues(configs, (con) => processMCPEnv({ options: con }));
|
||||||
this.connections = new ConnectionsRepository(configs);
|
this.connections = new ConnectionsRepository(configs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { MCPOAuthTokens } from '~/mcp/oauth';
|
||||||
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||||
import { MCPServersRegistry } from '~/mcp/MCPServersRegistry';
|
import { MCPServersRegistry } from '~/mcp/MCPServersRegistry';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
|
import type { RequestBody } from '~/types';
|
||||||
import type * as t from './types';
|
import type * as t from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,6 +48,7 @@ export abstract class UserConnectionManager {
|
||||||
serverName,
|
serverName,
|
||||||
flowManager,
|
flowManager,
|
||||||
customUserVars,
|
customUserVars,
|
||||||
|
requestBody,
|
||||||
tokenMethods,
|
tokenMethods,
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
|
|
@ -57,6 +59,7 @@ export abstract class UserConnectionManager {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||||
customUserVars?: Record<string, string>;
|
customUserVars?: Record<string, string>;
|
||||||
|
requestBody?: RequestBody;
|
||||||
tokenMethods?: TokenMethods;
|
tokenMethods?: TokenMethods;
|
||||||
oauthStart?: (authURL: string) => Promise<void>;
|
oauthStart?: (authURL: string) => Promise<void>;
|
||||||
oauthEnd?: () => Promise<void>;
|
oauthEnd?: () => Promise<void>;
|
||||||
|
|
@ -127,6 +130,7 @@ export abstract class UserConnectionManager {
|
||||||
oauthStart: oauthStart,
|
oauthStart: oauthStart,
|
||||||
oauthEnd: oauthEnd,
|
oauthEnd: oauthEnd,
|
||||||
returnOnOAuth: returnOnOAuth,
|
returnOnOAuth: returnOnOAuth,
|
||||||
|
requestBody: requestBody,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { logger } from '@librechat/data-schemas';
|
import { logger } from '@librechat/data-schemas';
|
||||||
|
import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
import type { TUser } from 'librechat-data-provider';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
import type { MCPOAuthTokens } from '~/mcp/oauth';
|
import type { MCPOAuthTokens } from '~/mcp/oauth';
|
||||||
import { MCPConnectionFactory } from '../MCPConnectionFactory';
|
import type * as t from '~/mcp/types';
|
||||||
|
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||||
|
import { MCPConnection } from '~/mcp/connection';
|
||||||
import { MCPOAuthHandler } from '~/mcp/oauth';
|
import { MCPOAuthHandler } from '~/mcp/oauth';
|
||||||
import { MCPConnection } from '../connection';
|
|
||||||
import { processMCPEnv } from '~/utils';
|
import { processMCPEnv } from '~/utils';
|
||||||
import type * as t from '../types';
|
|
||||||
|
|
||||||
jest.mock('../connection');
|
jest.mock('~/mcp/connection');
|
||||||
jest.mock('~/mcp/oauth');
|
jest.mock('~/mcp/oauth');
|
||||||
jest.mock('~/utils');
|
jest.mock('~/utils');
|
||||||
jest.mock('@librechat/data-schemas', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
|
@ -74,7 +75,7 @@ describe('MCPConnectionFactory', () => {
|
||||||
const connection = await MCPConnectionFactory.create(basicOptions);
|
const connection = await MCPConnectionFactory.create(basicOptions);
|
||||||
|
|
||||||
expect(connection).toBe(mockConnectionInstance);
|
expect(connection).toBe(mockConnectionInstance);
|
||||||
expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, undefined, undefined);
|
expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig });
|
||||||
expect(mockMCPConnection).toHaveBeenCalledWith({
|
expect(mockMCPConnection).toHaveBeenCalledWith({
|
||||||
serverName: 'test-server',
|
serverName: 'test-server',
|
||||||
serverConfig: mockServerConfig,
|
serverConfig: mockServerConfig,
|
||||||
|
|
@ -115,7 +116,7 @@ describe('MCPConnectionFactory', () => {
|
||||||
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
||||||
|
|
||||||
expect(connection).toBe(mockConnectionInstance);
|
expect(connection).toBe(mockConnectionInstance);
|
||||||
expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, mockUser, undefined);
|
expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig, user: mockUser });
|
||||||
expect(mockMCPConnection).toHaveBeenCalledWith({
|
expect(mockMCPConnection).toHaveBeenCalledWith({
|
||||||
serverName: 'test-server',
|
serverName: 'test-server',
|
||||||
serverConfig: mockServerConfig,
|
serverConfig: mockServerConfig,
|
||||||
|
|
@ -132,12 +133,12 @@ describe('MCPConnectionFactory', () => {
|
||||||
serverConfig: mockServerConfig,
|
serverConfig: mockServerConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const oauthOptions = {
|
const oauthOptions: t.OAuthConnectionOptions = {
|
||||||
useOAuth: true as const,
|
useOAuth: true as const,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
flowManager: mockFlowManager,
|
flowManager: mockFlowManager,
|
||||||
tokenMethods: {
|
tokenMethods: {
|
||||||
findToken: undefined as unknown as () => Promise<any>,
|
findToken: undefined as unknown as TokenMethods['findToken'],
|
||||||
createToken: jest.fn(),
|
createToken: jest.fn(),
|
||||||
updateToken: jest.fn(),
|
updateToken: jest.fn(),
|
||||||
deleteTokens: jest.fn(),
|
deleteTokens: jest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||||
// Mock processMCPEnv to verify it's called and adds a processed marker
|
// Mock processMCPEnv to verify it's called and adds a processed marker
|
||||||
jest.mock('~/utils', () => ({
|
jest.mock('~/utils', () => ({
|
||||||
...jest.requireActual('~/utils'),
|
...jest.requireActual('~/utils'),
|
||||||
processMCPEnv: jest.fn((config) => ({
|
processMCPEnv: jest.fn(({ options }) => ({
|
||||||
...config,
|
...options,
|
||||||
_processed: true, // Simple marker to verify processing occurred
|
_processed: true, // Simple marker to verify processing occurred
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(originalObj);
|
const result = processMCPEnv({ options: originalObj });
|
||||||
|
|
||||||
// Verify it's not the same object reference
|
// Verify it's not the same object reference
|
||||||
expect(result).not.toBe(originalObj);
|
expect(result).not.toBe(originalObj);
|
||||||
|
|
@ -192,7 +192,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process environment variables in env field', () => {
|
it('should process environment variables in env field', () => {
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -203,7 +203,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj);
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
expect('env' in result && result.env).toEqual({
|
expect('env' in result && result.env).toEqual({
|
||||||
API_KEY: 'test-api-key-value',
|
API_KEY: 'test-api-key-value',
|
||||||
|
|
@ -215,7 +215,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
|
|
||||||
it('should process user ID in headers field', () => {
|
it('should process user ID in headers field', () => {
|
||||||
const user = createTestUser({ id: 'test-user-123' });
|
const user = createTestUser({ id: 'test-user-123' });
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -225,7 +225,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: 'test-api-key-value',
|
Authorization: 'test-api-key-value',
|
||||||
|
|
@ -236,22 +236,22 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
|
|
||||||
it('should handle null or undefined input', () => {
|
it('should handle null or undefined input', () => {
|
||||||
// @ts-ignore - Testing null/undefined handling
|
// @ts-ignore - Testing null/undefined handling
|
||||||
expect(processMCPEnv(null)).toBeNull();
|
expect(processMCPEnv({ options: null })).toBeNull();
|
||||||
// @ts-ignore - Testing null/undefined handling
|
// @ts-ignore - Testing null/undefined handling
|
||||||
expect(processMCPEnv(undefined)).toBeUndefined();
|
expect(processMCPEnv({ options: undefined })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not modify objects without env or headers', () => {
|
it('should not modify objects without env or headers', () => {
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj);
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
expect(result).toEqual(obj);
|
expect(result).toEqual(options);
|
||||||
expect(result).not.toBe(obj); // Still a different object (deep clone)
|
expect(result).not.toBe(options); // Still a different object (deep clone)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ensure different users with same starting config get separate values', () => {
|
it('should ensure different users with same starting config get separate values', () => {
|
||||||
|
|
@ -269,8 +269,8 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
const user1 = createTestUser({ id: 'user-123' });
|
const user1 = createTestUser({ id: 'user-123' });
|
||||||
const user2 = createTestUser({ id: 'user-456' });
|
const user2 = createTestUser({ id: 'user-456' });
|
||||||
|
|
||||||
const resultUser1 = processMCPEnv(baseConfig, user1);
|
const resultUser1 = processMCPEnv({ options: baseConfig, user: user1 });
|
||||||
const resultUser2 = processMCPEnv(baseConfig, user2);
|
const resultUser2 = processMCPEnv({ options: baseConfig, user: user2 });
|
||||||
|
|
||||||
// Verify each has the correct user ID
|
// Verify each has the correct user ID
|
||||||
expect('headers' in resultUser1 && resultUser1.headers?.['User-Id']).toBe('user-123');
|
expect('headers' in resultUser1 && resultUser1.headers?.['User-Id']).toBe('user-123');
|
||||||
|
|
@ -293,7 +293,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
|
|
||||||
it('should process headers in streamable-http options', () => {
|
it('should process headers in streamable-http options', () => {
|
||||||
const user = createTestUser({ id: 'test-user-123' });
|
const user = createTestUser({ id: 'test-user-123' });
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'streamable-http',
|
type: 'streamable-http',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -303,7 +303,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: 'test-api-key-value',
|
Authorization: 'test-api-key-value',
|
||||||
|
|
@ -313,12 +313,12 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should maintain streamable-http type in processed options', () => {
|
it('should maintain streamable-http type in processed options', () => {
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'streamable-http',
|
type: 'streamable-http',
|
||||||
url: 'https://example.com/api',
|
url: 'https://example.com/api',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj);
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
expect(result.type).toBe('streamable-http');
|
expect(result.type).toBe('streamable-http');
|
||||||
});
|
});
|
||||||
|
|
@ -329,7 +329,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
url: 'https://example.com/api',
|
url: 'https://example.com/api',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj as unknown as MCPOptions);
|
const result = processMCPEnv({ options: obj as unknown as MCPOptions });
|
||||||
|
|
||||||
expect(result.type).toBe('http');
|
expect(result.type).toBe('http');
|
||||||
});
|
});
|
||||||
|
|
@ -346,7 +346,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj as unknown as MCPOptions, user);
|
const result = processMCPEnv({ options: obj as unknown as MCPOptions, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: 'test-api-key-value',
|
Authorization: 'test-api-key-value',
|
||||||
|
|
@ -365,7 +365,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -379,7 +379,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'User-Email': 'test@example.com',
|
'User-Email': 'test@example.com',
|
||||||
|
|
@ -398,7 +398,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
username: undefined, // explicitly set to undefined to test missing field
|
username: undefined, // explicitly set to undefined to test missing field
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -408,7 +408,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'User-Email': 'test@example.com',
|
'User-Email': 'test@example.com',
|
||||||
|
|
@ -423,7 +423,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
ldapId: 'ldap-user-123',
|
ldapId: 'ldap-user-123',
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -433,7 +433,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('env' in result && result.env).toEqual({
|
expect('env' in result && result.env).toEqual({
|
||||||
USER_EMAIL: 'test@example.com',
|
USER_EMAIL: 'test@example.com',
|
||||||
|
|
@ -447,12 +447,12 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
id: 'user-123',
|
id: 'user-123',
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com/api/{{LIBRECHAT_USER_USERNAME}}/stream',
|
url: 'https://example.com/api/{{LIBRECHAT_USER_USERNAME}}/stream',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('url' in result && result.url).toBe('https://example.com/api/testuser/stream');
|
expect('url' in result && result.url).toBe('https://example.com/api/testuser/stream');
|
||||||
});
|
});
|
||||||
|
|
@ -464,7 +464,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -474,7 +474,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'Email-Verified': 'true',
|
'Email-Verified': 'true',
|
||||||
|
|
@ -489,7 +489,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'secret-password',
|
password: 'secret-password',
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -498,7 +498,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'User-Email': 'test@example.com',
|
'User-Email': 'test@example.com',
|
||||||
|
|
@ -511,7 +511,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
id: 'user-123',
|
id: 'user-123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
});
|
});
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -521,7 +521,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'Primary-Email': 'test@example.com',
|
'Primary-Email': 'test@example.com',
|
||||||
|
|
@ -544,7 +544,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result1 = processMCPEnv(obj1, userWithId);
|
const result1 = processMCPEnv({ options: obj1, user: userWithId });
|
||||||
expect('headers' in result1 && result1.headers?.['User-Id']).toBe('user-123');
|
expect('headers' in result1 && result1.headers?.['User-Id']).toBe('user-123');
|
||||||
|
|
||||||
// Test with '_id' property only (should not work since we only check 'id')
|
// Test with '_id' property only (should not work since we only check 'id')
|
||||||
|
|
@ -561,7 +561,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result2 = processMCPEnv(obj2, userWithUnderscore);
|
const result2 = processMCPEnv({ options: obj2, user: userWithUnderscore });
|
||||||
// Since we don't check _id, the placeholder should remain unchanged
|
// Since we don't check _id, the placeholder should remain unchanged
|
||||||
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
|
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
|
||||||
|
|
||||||
|
|
@ -579,7 +579,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result3 = processMCPEnv(obj3, userWithBoth);
|
const result3 = processMCPEnv({ options: obj3, user: userWithBoth });
|
||||||
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
|
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -589,7 +589,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
CUSTOM_VAR_1: 'custom-value-1',
|
CUSTOM_VAR_1: 'custom-value-1',
|
||||||
CUSTOM_VAR_2: 'custom-value-2',
|
CUSTOM_VAR_2: 'custom-value-2',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -600,7 +600,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
|
|
||||||
expect('env' in result && result.env).toEqual({
|
expect('env' in result && result.env).toEqual({
|
||||||
VAR_A: 'custom-value-1',
|
VAR_A: 'custom-value-1',
|
||||||
|
|
@ -616,7 +616,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
USER_TOKEN: 'user-specific-token',
|
USER_TOKEN: 'user-specific-token',
|
||||||
REGION: 'us-west-1',
|
REGION: 'us-west-1',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com/api',
|
url: 'https://example.com/api',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -627,7 +627,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: 'Bearer user-specific-token',
|
Authorization: 'Bearer user-specific-token',
|
||||||
|
|
@ -643,12 +643,12 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
API_VERSION: 'v2',
|
API_VERSION: 'v2',
|
||||||
TENANT_ID: 'tenant123',
|
TENANT_ID: 'tenant123',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'websocket',
|
type: 'websocket',
|
||||||
url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}',
|
url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
|
|
||||||
expect('url' in result && result.url).toBe(
|
expect('url' in result && result.url).toBe(
|
||||||
'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value',
|
'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value',
|
||||||
|
|
@ -664,7 +664,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
MY_API_KEY: 'user-provided-api-key-12345',
|
MY_API_KEY: 'user-provided-api-key-12345',
|
||||||
PROFILE_NAME: 'production-profile',
|
PROFILE_NAME: 'production-profile',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'npx',
|
command: 'npx',
|
||||||
args: [
|
args: [
|
||||||
'-y',
|
'-y',
|
||||||
|
|
@ -680,7 +680,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
|
|
||||||
expect('args' in result && result.args).toEqual([
|
expect('args' in result && result.args).toEqual([
|
||||||
'-y',
|
'-y',
|
||||||
|
|
@ -704,7 +704,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
const customUserVars = {
|
const customUserVars = {
|
||||||
LIBRECHAT_USER_EMAIL: 'custom-email-wins',
|
LIBRECHAT_USER_EMAIL: 'custom-email-wins',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com/api',
|
url: 'https://example.com/api',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -712,7 +712,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins');
|
expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins');
|
||||||
|
|
||||||
// Clean up env var
|
// Clean up env var
|
||||||
|
|
@ -724,7 +724,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
const customUserVars = {
|
const customUserVars = {
|
||||||
UNUSED_VAR: 'unused-value',
|
UNUSED_VAR: 'unused-value',
|
||||||
};
|
};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -732,7 +732,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
expect('env' in result && result.env).toEqual({
|
expect('env' in result && result.env).toEqual({
|
||||||
API_KEY: 'test-api-key-value',
|
API_KEY: 'test-api-key-value',
|
||||||
});
|
});
|
||||||
|
|
@ -742,7 +742,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
const user = createTestUser({ email: 'user-provided-email@example.com' });
|
const user = createTestUser({ email: 'user-provided-email@example.com' });
|
||||||
// No customUserVars provided or customUserVars is empty
|
// No customUserVars provided or customUserVars is empty
|
||||||
const customUserVars = {};
|
const customUserVars = {};
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://example.com/api',
|
url: 'https://example.com/api',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -752,7 +752,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
'User-Email-Header': 'user-provided-email@example.com',
|
'User-Email-Header': 'user-provided-email@example.com',
|
||||||
'System-Key-Header': 'test-api-key-value',
|
'System-Key-Header': 'test-api-key-value',
|
||||||
|
|
@ -792,7 +792,11 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
// Cast obj to MCPOptions when calling processMCPEnv.
|
// Cast obj to MCPOptions when calling processMCPEnv.
|
||||||
// This acknowledges the object might not strictly conform to one schema in the union,
|
// 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.
|
// but we are testing the function's ability to handle these properties if present.
|
||||||
const result = processMCPEnv(obj as MCPOptions, user, allCustomVarsForCall);
|
const result = processMCPEnv({
|
||||||
|
options: obj as MCPOptions,
|
||||||
|
user,
|
||||||
|
customUserVars: allCustomVarsForCall,
|
||||||
|
});
|
||||||
|
|
||||||
expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe');
|
expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe');
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
|
|
@ -814,7 +818,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simulate the GitHub MCP server configuration from librechat.yaml
|
// Simulate the GitHub MCP server configuration from librechat.yaml
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'streamable-http',
|
type: 'streamable-http',
|
||||||
url: 'https://api.githubcopilot.com/mcp/',
|
url: 'https://api.githubcopilot.com/mcp/',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -824,7 +828,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user, customUserVars);
|
const result = processMCPEnv({ options, user, customUserVars });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678',
|
Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678',
|
||||||
|
|
@ -838,7 +842,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
it('should handle GitHub MCP server configuration without PAT_TOKEN (placeholder remains)', () => {
|
it('should handle GitHub MCP server configuration without PAT_TOKEN (placeholder remains)', () => {
|
||||||
const user = createTestUser({ id: 'github-user-123' });
|
const user = createTestUser({ id: 'github-user-123' });
|
||||||
// No customUserVars provided - PAT_TOKEN should remain as placeholder
|
// No customUserVars provided - PAT_TOKEN should remain as placeholder
|
||||||
const obj: MCPOptions = {
|
const options: MCPOptions = {
|
||||||
type: 'streamable-http',
|
type: 'streamable-http',
|
||||||
url: 'https://api.githubcopilot.com/mcp/',
|
url: 'https://api.githubcopilot.com/mcp/',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -847,7 +851,7 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = processMCPEnv(obj, user);
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
expect('headers' in result && result.headers).toEqual({
|
expect('headers' in result && result.headers).toEqual({
|
||||||
Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided
|
Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided
|
||||||
|
|
|
||||||
|
|
@ -81,11 +81,21 @@ export class MCPConnection extends EventEmitter {
|
||||||
private lastPingTime: number;
|
private lastPingTime: number;
|
||||||
private lastConnectionCheckAt: number = 0;
|
private lastConnectionCheckAt: number = 0;
|
||||||
private oauthTokens?: MCPOAuthTokens | null;
|
private oauthTokens?: MCPOAuthTokens | null;
|
||||||
|
private requestHeaders?: Record<string, string> | null;
|
||||||
private oauthRequired = false;
|
private oauthRequired = false;
|
||||||
iconPath?: string;
|
iconPath?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
||||||
|
setRequestHeaders(headers: Record<string, string> | null): void {
|
||||||
|
logger.debug(`${this.getLogPrefix()} Setting request headers: ${JSON.stringify(headers)}`);
|
||||||
|
this.requestHeaders = headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRequestHeaders(): Record<string, string> | null | undefined {
|
||||||
|
return this.requestHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(params: MCPConnectionParams) {
|
constructor(params: MCPConnectionParams) {
|
||||||
super();
|
super();
|
||||||
this.options = params.serverConfig;
|
this.options = params.serverConfig;
|
||||||
|
|
@ -116,6 +126,43 @@ export class MCPConnection extends EventEmitter {
|
||||||
return `[MCP]${userPart}[${this.serverName}]`;
|
return `[MCP]${userPart}[${this.serverName}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create fetch functions without capturing the entire `this` context.
|
||||||
|
* This helps prevent memory leaks by only passing necessary dependencies.
|
||||||
|
*
|
||||||
|
* @param getHeaders Function to retrieve request headers
|
||||||
|
* @returns A fetch function that merges headers appropriately
|
||||||
|
*/
|
||||||
|
private createFetchFunction(
|
||||||
|
getHeaders: () => Record<string, string> | null | undefined,
|
||||||
|
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||||
|
return function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
const requestHeaders = getHeaders();
|
||||||
|
if (!requestHeaders) {
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
let initHeaders: Record<string, string> = {};
|
||||||
|
if (init?.headers) {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
initHeaders = Object.fromEntries(init.headers.entries());
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
initHeaders = Object.fromEntries(init.headers);
|
||||||
|
} else {
|
||||||
|
initHeaders = init.headers as Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...initHeaders,
|
||||||
|
...requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private emitError(error: unknown, errorContext: string): void {
|
private emitError(error: unknown, errorContext: string): void {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`);
|
logger.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`);
|
||||||
|
|
@ -188,6 +235,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)),
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
|
|
@ -214,7 +262,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
);
|
);
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
// Add OAuth token to headers if available
|
/** Add OAuth token to headers if available */
|
||||||
const headers = { ...options.headers };
|
const headers = { ...options.headers };
|
||||||
if (this.oauthTokens?.access_token) {
|
if (this.oauthTokens?.access_token) {
|
||||||
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
|
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
|
||||||
|
|
@ -225,6 +273,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
headers,
|
headers,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
},
|
},
|
||||||
|
fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)),
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ import {
|
||||||
WebSocketOptionsSchema,
|
WebSocketOptionsSchema,
|
||||||
StreamableHTTPOptionsSchema,
|
StreamableHTTPOptionsSchema,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
|
import type { TPlugin, TUser } from 'librechat-data-provider';
|
||||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { TPlugin } from 'librechat-data-provider';
|
import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
import type { JsonSchemaType } from '~/types/zod';
|
import type { JsonSchemaType } from '~/types/zod';
|
||||||
|
import type { RequestBody } from '~/types/http';
|
||||||
|
import type * as o from '~/mcp/oauth/types';
|
||||||
|
|
||||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||||
|
|
@ -113,3 +117,21 @@ export type ParsedServerConfig = MCPOptions & {
|
||||||
capabilities?: string;
|
capabilities?: string;
|
||||||
tools?: string;
|
tools?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface BasicConnectionOptions {
|
||||||
|
serverName: string;
|
||||||
|
serverConfig: MCPOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthConnectionOptions {
|
||||||
|
user: TUser;
|
||||||
|
useOAuth: true;
|
||||||
|
requestBody?: RequestBody;
|
||||||
|
customUserVars?: Record<string, string>;
|
||||||
|
flowManager: FlowStateManager<o.MCPOAuthTokens | null>;
|
||||||
|
tokenMethods?: TokenMethods;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
oauthStart?: (authURL: string) => Promise<void>;
|
||||||
|
oauthEnd?: () => Promise<void>;
|
||||||
|
returnOnOAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
9
packages/api/src/types/http.ts
Normal file
9
packages/api/src/types/http.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* LibreChat-specific request body type that extends Express Request body
|
||||||
|
* (have to use type alias because you can't extend indexed access types like Request['body'])
|
||||||
|
*/
|
||||||
|
export type RequestBody = {
|
||||||
|
messageId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
parentMessageId?: string;
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ export * from './balance';
|
||||||
export * from './events';
|
export * from './events';
|
||||||
export * from './error';
|
export * from './error';
|
||||||
export * from './google';
|
export * from './google';
|
||||||
|
export * from './http';
|
||||||
export * from './mistral';
|
export * from './mistral';
|
||||||
export * from './openai';
|
export * from './openai';
|
||||||
export * from './prompts';
|
export * from './prompts';
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,14 @@ describe('resolveHeaders', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty object when headers is null', () => {
|
it('should return empty object when headers is null', () => {
|
||||||
const result = resolveHeaders(null as unknown as Record<string, string> | undefined);
|
const result = resolveHeaders({
|
||||||
|
headers: null as unknown as Record<string, string>,
|
||||||
|
});
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty object when headers is empty', () => {
|
it('should return empty object when headers is empty', () => {
|
||||||
const result = resolveHeaders({});
|
const result = resolveHeaders({ headers: {} });
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -52,7 +54,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers);
|
const result = resolveHeaders({ headers });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
Authorization: 'test-api-key-value',
|
Authorization: 'test-api-key-value',
|
||||||
|
|
@ -68,7 +70,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Id': 'test-user-123',
|
'User-Id': 'test-user-123',
|
||||||
|
|
@ -82,7 +84,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers);
|
const result = resolveHeaders({ headers });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
|
@ -97,7 +99,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
|
@ -123,7 +125,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Email': 'test@example.com',
|
'User-Email': 'test@example.com',
|
||||||
|
|
@ -148,7 +150,7 @@ describe('resolveHeaders', () => {
|
||||||
'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}',
|
'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Email': 'test@example.com',
|
'User-Email': 'test@example.com',
|
||||||
|
|
@ -171,7 +173,7 @@ describe('resolveHeaders', () => {
|
||||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user, customUserVars);
|
const result = resolveHeaders({ headers, user, customUserVars });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
Authorization: 'Bearer user-specific-token',
|
Authorization: 'Bearer user-specific-token',
|
||||||
|
|
@ -194,7 +196,7 @@ describe('resolveHeaders', () => {
|
||||||
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user, customUserVars);
|
const result = resolveHeaders({ headers, user, customUserVars });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'Test-Email': 'custom-email@example.com',
|
'Test-Email': 'custom-email@example.com',
|
||||||
|
|
@ -213,7 +215,7 @@ describe('resolveHeaders', () => {
|
||||||
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
'User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'User-Role': 'admin',
|
'User-Role': 'admin',
|
||||||
|
|
@ -233,7 +235,7 @@ describe('resolveHeaders', () => {
|
||||||
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'Primary-Email': 'test@example.com',
|
'Primary-Email': 'test@example.com',
|
||||||
|
|
@ -259,7 +261,7 @@ describe('resolveHeaders', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user, customUserVars);
|
const result = resolveHeaders({ headers, user, customUserVars });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
Authorization: 'Bearer secret-token',
|
Authorization: 'Bearer secret-token',
|
||||||
|
|
@ -277,7 +279,7 @@ describe('resolveHeaders', () => {
|
||||||
};
|
};
|
||||||
const user = { id: 'user-123' };
|
const user = { id: 'user-123' };
|
||||||
|
|
||||||
const result = resolveHeaders(originalHeaders, user);
|
const result = resolveHeaders({ headers: originalHeaders, user });
|
||||||
|
|
||||||
// Verify the result is processed
|
// Verify the result is processed
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -306,7 +308,7 @@ describe('resolveHeaders', () => {
|
||||||
'Dot-Header': '{{CUSTOM.VAR}}',
|
'Dot-Header': '{{CUSTOM.VAR}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user, customUserVars);
|
const result = resolveHeaders({ headers, user, customUserVars });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'Dash-Header': 'dash-value',
|
'Dash-Header': 'dash-value',
|
||||||
|
|
@ -357,7 +359,7 @@ describe('resolveHeaders', () => {
|
||||||
'X-User-TermsAccepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
|
'X-User-TermsAccepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
|
|
||||||
expect(result['X-User-ID']).toBe('abc');
|
expect(result['X-User-ID']).toBe('abc');
|
||||||
expect(result['X-User-Name']).toBe('Test User');
|
expect(result['X-User-Name']).toBe('Test User');
|
||||||
|
|
@ -384,7 +386,7 @@ describe('resolveHeaders', () => {
|
||||||
'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}',
|
'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}',
|
||||||
};
|
};
|
||||||
const customVars = { MY_CUSTOM: 'custom-value' };
|
const customVars = { MY_CUSTOM: 'custom-value' };
|
||||||
const result = resolveHeaders(headers, user, customVars);
|
const result = resolveHeaders({ headers, user, customUserVars: customVars });
|
||||||
expect(result['X-Multi']).toBe('User: abc, Env: test-api-key-value, Custom: custom-value');
|
expect(result['X-Multi']).toBe('User: abc, Env: test-api-key-value, Custom: custom-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -394,7 +396,7 @@ describe('resolveHeaders', () => {
|
||||||
'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}',
|
'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}',
|
||||||
'X-Known': '{{LIBRECHAT_USER_ID}}',
|
'X-Known': '{{LIBRECHAT_USER_ID}}',
|
||||||
};
|
};
|
||||||
const result = resolveHeaders(headers, user);
|
const result = resolveHeaders({ headers, user });
|
||||||
expect(result['X-Unknown']).toBe('{{SOMETHING_NOT_RECOGNIZED}}');
|
expect(result['X-Unknown']).toBe('{{SOMETHING_NOT_RECOGNIZED}}');
|
||||||
expect(result['X-Known']).toBe('abc');
|
expect(result['X-Known']).toBe('abc');
|
||||||
});
|
});
|
||||||
|
|
@ -416,7 +418,7 @@ describe('resolveHeaders', () => {
|
||||||
'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
|
'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
|
||||||
};
|
};
|
||||||
const customVars = { MY_CUSTOM: 'custom-value' };
|
const customVars = { MY_CUSTOM: 'custom-value' };
|
||||||
const result = resolveHeaders(headers, user, customVars);
|
const result = resolveHeaders({ headers, user, customUserVars: customVars });
|
||||||
|
|
||||||
expect(result['X-User']).toBe('abc');
|
expect(result['X-User']).toBe('abc');
|
||||||
expect(result['X-Env']).toBe('test-api-key-value');
|
expect(result['X-Env']).toBe('test-api-key-value');
|
||||||
|
|
@ -426,4 +428,15 @@ describe('resolveHeaders', () => {
|
||||||
expect(result['X-Empty']).toBe('');
|
expect(result['X-Empty']).toBe('');
|
||||||
expect(result['X-Boolean']).toBe('true');
|
expect(result['X-Boolean']).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should process LIBRECHAT_BODY placeholders', () => {
|
||||||
|
const body = {
|
||||||
|
conversationId: 'conv-123',
|
||||||
|
parentMessageId: 'parent-456',
|
||||||
|
messageId: 'msg-789',
|
||||||
|
};
|
||||||
|
const headers = { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' };
|
||||||
|
const result = resolveHeaders({ headers, body });
|
||||||
|
expect(result['X-Conversation']).toBe('conv-123');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { extractEnvVariable } from 'librechat-data-provider';
|
import { extractEnvVariable } from 'librechat-data-provider';
|
||||||
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
||||||
|
import type { RequestBody } from '~/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of allowed user fields that can be used in MCP environment variables.
|
* List of allowed user fields that can be used in MCP environment variables.
|
||||||
|
|
@ -25,6 +26,12 @@ const ALLOWED_USER_FIELDS = [
|
||||||
'termsAccepted',
|
'termsAccepted',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of allowed request body fields that can be used in header placeholders.
|
||||||
|
* These are common fields from the request body that are safe to expose in headers.
|
||||||
|
*/
|
||||||
|
const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a string value to replace user field placeholders
|
* Processes a string value to replace user field placeholders
|
||||||
* @param value - The string value to process
|
* @param value - The string value to process
|
||||||
|
|
@ -61,21 +68,48 @@ function processUserPlaceholders(value: string, user?: TUser): string {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces request body field placeholders within a string.
|
||||||
|
* Recognized placeholders: `{{LIBRECHAT_BODY_<FIELD>}}` where `<FIELD>` ∈ ALLOWED_BODY_FIELDS.
|
||||||
|
* If a body field is absent or null/undefined, it is replaced with an empty string.
|
||||||
|
*
|
||||||
|
* @param value - The string value to process
|
||||||
|
* @param body - The request body object
|
||||||
|
* @returns The processed string with placeholders replaced
|
||||||
|
*/
|
||||||
|
function processBodyPlaceholders(value: string, body: RequestBody): string {
|
||||||
|
for (const field of ALLOWED_BODY_FIELDS) {
|
||||||
|
const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`;
|
||||||
|
if (!value.includes(placeholder)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldValue = body[field];
|
||||||
|
const replacementValue = fieldValue == null ? '' : String(fieldValue);
|
||||||
|
value = value.replace(new RegExp(placeholder, 'g'), replacementValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a single string value by replacing various types of placeholders
|
* Processes a single string value by replacing various types of placeholders
|
||||||
* @param originalValue - The original string value to process
|
* @param originalValue - The original string value to process
|
||||||
* @param customUserVars - Optional custom user variables to replace placeholders
|
* @param customUserVars - Optional custom user variables to replace placeholders
|
||||||
* @param user - Optional user object for replacing user field placeholders
|
* @param user - Optional user object for replacing user field placeholders
|
||||||
|
* @param body - Optional request body object for replacing body field placeholders
|
||||||
* @returns The processed string with all placeholders replaced
|
* @returns The processed string with all placeholders replaced
|
||||||
*/
|
*/
|
||||||
function processSingleValue({
|
function processSingleValue({
|
||||||
originalValue,
|
originalValue,
|
||||||
customUserVars,
|
customUserVars,
|
||||||
user,
|
user,
|
||||||
|
body = undefined,
|
||||||
}: {
|
}: {
|
||||||
originalValue: string;
|
originalValue: string;
|
||||||
customUserVars?: Record<string, string>;
|
customUserVars?: Record<string, string>;
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
|
body?: RequestBody;
|
||||||
}): string {
|
}): string {
|
||||||
let value = originalValue;
|
let value = originalValue;
|
||||||
|
|
||||||
|
|
@ -92,7 +126,12 @@ function processSingleValue({
|
||||||
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
|
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
|
||||||
value = processUserPlaceholders(value, user);
|
value = processUserPlaceholders(value, user);
|
||||||
|
|
||||||
// 3. Replace system environment variables
|
// 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}})
|
||||||
|
if (body) {
|
||||||
|
value = processBodyPlaceholders(value, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Replace system environment variables
|
||||||
value = extractEnvVariable(value);
|
value = extractEnvVariable(value);
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -100,26 +139,31 @@ function processSingleValue({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively processes an object to replace environment variables in string values
|
* Recursively processes an object to replace environment variables in string values
|
||||||
* @param obj - The object to process
|
* @param params - Processing parameters
|
||||||
* @param user - The user object containing all user fields
|
* @param params.options - The MCP options to process
|
||||||
* @param customUserVars - vars that user set in settings
|
* @param params.user - The user object containing all user fields
|
||||||
|
* @param params.customUserVars - vars that user set in settings
|
||||||
|
* @param params.body - the body of the request that is being processed
|
||||||
* @returns - The processed object with environment variables replaced
|
* @returns - The processed object with environment variables replaced
|
||||||
*/
|
*/
|
||||||
export function processMCPEnv(
|
export function processMCPEnv(params: {
|
||||||
obj: Readonly<MCPOptions>,
|
options: Readonly<MCPOptions>;
|
||||||
user?: TUser,
|
user?: TUser;
|
||||||
customUserVars?: Record<string, string>,
|
customUserVars?: Record<string, string>;
|
||||||
): MCPOptions {
|
body?: RequestBody;
|
||||||
if (obj === null || obj === undefined) {
|
}): MCPOptions {
|
||||||
return obj;
|
const { options, user, customUserVars, body } = params;
|
||||||
|
|
||||||
|
if (options === null || options === undefined) {
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newObj: MCPOptions = structuredClone(obj);
|
const newObj: MCPOptions = structuredClone(options);
|
||||||
|
|
||||||
if ('env' in newObj && newObj.env) {
|
if ('env' in newObj && newObj.env) {
|
||||||
const processedEnv: Record<string, string> = {};
|
const processedEnv: Record<string, string> = {};
|
||||||
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
||||||
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
|
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user, body });
|
||||||
}
|
}
|
||||||
newObj.env = processedEnv;
|
newObj.env = processedEnv;
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +171,7 @@ export function processMCPEnv(
|
||||||
if ('args' in newObj && newObj.args) {
|
if ('args' in newObj && newObj.args) {
|
||||||
const processedArgs: string[] = [];
|
const processedArgs: string[] = [];
|
||||||
for (const originalValue of newObj.args) {
|
for (const originalValue of newObj.args) {
|
||||||
processedArgs.push(processSingleValue({ originalValue, customUserVars, user }));
|
processedArgs.push(processSingleValue({ originalValue, customUserVars, user, body }));
|
||||||
}
|
}
|
||||||
newObj.args = processedArgs;
|
newObj.args = processedArgs;
|
||||||
}
|
}
|
||||||
|
|
@ -137,39 +181,47 @@ export function processMCPEnv(
|
||||||
if ('headers' in newObj && newObj.headers) {
|
if ('headers' in newObj && newObj.headers) {
|
||||||
const processedHeaders: Record<string, string> = {};
|
const processedHeaders: Record<string, string> = {};
|
||||||
for (const [key, originalValue] of Object.entries(newObj.headers)) {
|
for (const [key, originalValue] of Object.entries(newObj.headers)) {
|
||||||
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
|
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body });
|
||||||
}
|
}
|
||||||
newObj.headers = processedHeaders;
|
newObj.headers = processedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
|
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
|
||||||
if ('url' in newObj && newObj.url) {
|
if ('url' in newObj && newObj.url) {
|
||||||
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
|
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user, body });
|
||||||
}
|
}
|
||||||
|
|
||||||
return newObj;
|
return newObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves header values by replacing user placeholders, custom variables, and environment variables
|
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
|
||||||
* @param headers - The headers object to process
|
*
|
||||||
* @param user - Optional user object for replacing user field placeholders (can be partial with just id)
|
* @param options - Optional configuration object.
|
||||||
* @param customUserVars - Optional custom user variables to replace placeholders
|
* @param options.headers - The headers object to process.
|
||||||
* @returns - The processed headers with all placeholders replaced
|
* @param options.user - Optional user object for replacing user field placeholders (can be partial with just id).
|
||||||
|
* @param options.body - Optional request body object for replacing body field placeholders.
|
||||||
|
* @param options.customUserVars - Optional custom user variables to replace placeholders.
|
||||||
|
* @returns The processed headers with all placeholders replaced.
|
||||||
*/
|
*/
|
||||||
export function resolveHeaders(
|
export function resolveHeaders(options?: {
|
||||||
headers: Record<string, string> | undefined,
|
headers: Record<string, string> | undefined;
|
||||||
user?: Partial<TUser> | { id: string },
|
user?: Partial<TUser> | { id: string };
|
||||||
customUserVars?: Record<string, string>,
|
body?: RequestBody;
|
||||||
) {
|
customUserVars?: Record<string, string>;
|
||||||
const resolvedHeaders = { ...(headers ?? {}) };
|
}) {
|
||||||
|
const { headers, user, body, customUserVars } = options ?? {};
|
||||||
|
const inputHeaders = headers ?? {};
|
||||||
|
|
||||||
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
const resolvedHeaders: Record<string, string> = { ...inputHeaders };
|
||||||
Object.keys(headers).forEach((key) => {
|
|
||||||
|
if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) {
|
||||||
|
Object.keys(inputHeaders).forEach((key) => {
|
||||||
resolvedHeaders[key] = processSingleValue({
|
resolvedHeaders[key] = processSingleValue({
|
||||||
originalValue: headers[key],
|
originalValue: inputHeaders[key],
|
||||||
customUserVars,
|
customUserVars,
|
||||||
user: user as TUser,
|
user: user as TUser,
|
||||||
|
body,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue