🏷️ 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:
Danny Avila 2025-08-16 20:45:55 -04:00 committed by GitHub
parent 627f0bffe5
commit d7d02766ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 353 additions and 171 deletions

View file

@ -36,12 +36,14 @@ describe('resolveHeaders', () => {
});
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({});
});
it('should return empty object when headers is empty', () => {
const result = resolveHeaders({});
const result = resolveHeaders({ headers: {} });
expect(result).toEqual({});
});
@ -52,7 +54,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
Authorization: 'test-api-key-value',
@ -68,7 +70,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': 'test-user-123',
@ -82,7 +84,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@ -97,7 +99,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@ -123,7 +125,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@ -148,7 +150,7 @@ describe('resolveHeaders', () => {
'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@ -171,7 +173,7 @@ describe('resolveHeaders', () => {
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer user-specific-token',
@ -194,7 +196,7 @@ describe('resolveHeaders', () => {
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Test-Email': 'custom-email@example.com',
@ -213,7 +215,7 @@ describe('resolveHeaders', () => {
'User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Role': 'admin',
@ -233,7 +235,7 @@ describe('resolveHeaders', () => {
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'Primary-Email': 'test@example.com',
@ -259,7 +261,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer secret-token',
@ -277,7 +279,7 @@ describe('resolveHeaders', () => {
};
const user = { id: 'user-123' };
const result = resolveHeaders(originalHeaders, user);
const result = resolveHeaders({ headers: originalHeaders, user });
// Verify the result is processed
expect(result).toEqual({
@ -306,7 +308,7 @@ describe('resolveHeaders', () => {
'Dot-Header': '{{CUSTOM.VAR}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Dash-Header': 'dash-value',
@ -357,7 +359,7 @@ describe('resolveHeaders', () => {
'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-Name']).toBe('Test User');
@ -384,7 +386,7 @@ describe('resolveHeaders', () => {
'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}',
};
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');
});
@ -394,7 +396,7 @@ describe('resolveHeaders', () => {
'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}',
'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-Known']).toBe('abc');
});
@ -416,7 +418,7 @@ describe('resolveHeaders', () => {
'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
};
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-Env']).toBe('test-api-key-value');
@ -426,4 +428,15 @@ describe('resolveHeaders', () => {
expect(result['X-Empty']).toBe('');
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');
});
});

View file

@ -1,5 +1,6 @@
import { extractEnvVariable } 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.
@ -25,6 +26,12 @@ const ALLOWED_USER_FIELDS = [
'termsAccepted',
] 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
* @param value - The string value to process
@ -61,21 +68,48 @@ function processUserPlaceholders(value: string, user?: TUser): string {
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
* @param originalValue - The original string value to process
* @param customUserVars - Optional custom user variables to replace 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
*/
function processSingleValue({
originalValue,
customUserVars,
user,
body = undefined,
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: TUser;
body?: RequestBody;
}): string {
let value = originalValue;
@ -92,7 +126,12 @@ function processSingleValue({
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
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);
return value;
@ -100,26 +139,31 @@ function processSingleValue({
/**
* 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
* @param params - Processing parameters
* @param params.options - The MCP options to process
* @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
*/
export function processMCPEnv(
obj: Readonly<MCPOptions>,
user?: TUser,
customUserVars?: Record<string, string>,
): MCPOptions {
if (obj === null || obj === undefined) {
return obj;
export function processMCPEnv(params: {
options: Readonly<MCPOptions>;
user?: TUser;
customUserVars?: Record<string, string>;
body?: RequestBody;
}): MCPOptions {
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) {
const processedEnv: Record<string, string> = {};
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;
}
@ -127,7 +171,7 @@ export function processMCPEnv(
if ('args' in newObj && newObj.args) {
const processedArgs: string[] = [];
for (const originalValue of newObj.args) {
processedArgs.push(processSingleValue({ originalValue, customUserVars, user }));
processedArgs.push(processSingleValue({ originalValue, customUserVars, user, body }));
}
newObj.args = processedArgs;
}
@ -137,39 +181,47 @@ export function processMCPEnv(
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 });
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body });
}
newObj.headers = processedHeaders;
}
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
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;
}
/**
* Resolves header values by replacing user placeholders, 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 customUserVars - Optional custom user variables to replace placeholders
* @returns - The processed headers with all placeholders replaced
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
*
* @param options - Optional configuration object.
* @param options.headers - The headers object to process.
* @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(
headers: Record<string, string> | undefined,
user?: Partial<TUser> | { id: string },
customUserVars?: Record<string, string>,
) {
const resolvedHeaders = { ...(headers ?? {}) };
export function resolveHeaders(options?: {
headers: Record<string, string> | undefined;
user?: Partial<TUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}) {
const { headers, user, body, customUserVars } = options ?? {};
const inputHeaders = headers ?? {};
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
Object.keys(headers).forEach((key) => {
const resolvedHeaders: Record<string, string> = { ...inputHeaders };
if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) {
Object.keys(inputHeaders).forEach((key) => {
resolvedHeaders[key] = processSingleValue({
originalValue: headers[key],
originalValue: inputHeaders[key],
customUserVars,
user: user as TUser,
body,
});
});
}