🏷️ 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

@ -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 FinalizationRegistry = global.FinalizationRegistry || null;
@ -23,7 +23,7 @@ const clientRegistry = FinalizationRegistry
} else {
logger.debug('[FinalizationRegistry] Cleaning up client');
}
} catch (e) {
} catch {
// Ignore errors
}
})
@ -55,6 +55,9 @@ function disposeClient(client) {
if (client.responseMessageId) {
client.responseMessageId = null;
}
if (client.parentMessageId) {
client.parentMessageId = null;
}
if (client.message_file_map) {
client.message_file_map = null;
}
@ -334,7 +337,7 @@ function disposeClient(client) {
}
}
client.options = null;
} catch (e) {
} catch {
// Ignore errors during disposal
}
}

View file

@ -768,6 +768,11 @@ class AgentClient extends BaseClient {
last_agent_index: this.agentConfigs?.size ?? 0,
user_id: this.user ?? this.options.req.user?.id,
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
requestBody: {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
user: this.options.req.user,
},
recursionLimit: agentsEConfig?.recursionLimit ?? 25,

View file

@ -109,14 +109,14 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
apiKey = azureOptions.azureOpenAIApiKey;
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
opts.defaultHeaders = resolveHeaders(
{
opts.defaultHeaders = resolveHeaders({
headers: {
...headers,
'api-key': apiKey,
'OpenAI-Beta': `assistants=${version}`,
},
req.user,
);
user: req.user,
});
opts.model = azureOptions.azureOpenAIApiDeploymentName;
if (initAppClient) {

View file

@ -28,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
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)) {
throw new Error(`Missing API Key for ${endpoint}.`);

View file

@ -64,13 +64,14 @@ describe('custom/initializeClient', () => {
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');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith(
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
{ id: 'user-123', email: 'test@example.com' },
);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
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 () => {

View file

@ -81,10 +81,10 @@ const initializeClient = async ({
serverless = _serverless;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = resolveHeaders(
{ ...headers, ...(clientOptions.headers ?? {}) },
req.user,
);
clientOptions.headers = resolveHeaders({
headers: { ...headers, ...(clientOptions.headers ?? {}) },
user: req.user,
});
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;

View file

@ -186,6 +186,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
signal: derivedSignal,
},
user: config?.configurable?.user,
requestBody: config?.configurable?.requestBody,
customUserVars,
flowManager,
tokenMethods: {