🪡 fix: Thread Parent Message ID Through MCP Request-Scoped Bodies (#15095)

* fix: Unify MCP request-scoped headers

* fix: address request-scoped MCP review findings

* test: preserve request scope on status errors

* fix: treat authorized on-demand MCP servers as ready

* refactor: separate MCP readiness from connection state

* fix: preserve on-demand MCP readiness labels

* test: satisfy OpenAI conversation ownership guard

* fix: keep MCP action predicates boolean

* fix: close deferred MCP request context gaps

* fix: preserve on-demand MCP configuration actions

* fix: fail closed on unavailable MCP parent context

* test: complete MCP connecting-state mocks

* fix: preserve missing MCP parent on continuations

* fix: align native MCP request identities

* fix: preserve edited MCP parent identity

* test: use scoped Agent initializer fixture

* test: expose MCP request body helper

* fix: preserve MCP turn identity across resume

* style: sort stream metadata imports

* fix: carry normalized MCP identity to execution
This commit is contained in:
Danny Avila 2026-08-21 16:33:01 -04:00 committed by GitHub
parent f02ce63d57
commit 8ae94afa91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1500 additions and 199 deletions

View file

@ -125,6 +125,13 @@ jest.mock('@librechat/api', () => ({
buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions),
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
...(parentMessageId !== undefined && {
parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000',
}),
}),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
resolveAgentScopedSkillIds: jest
.fn()
@ -499,7 +506,10 @@ describe('OpenAIChatCompletionController', () => {
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['file_search'], 'agent-123');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
}),
);
});
@ -681,6 +691,79 @@ describe('OpenAIChatCompletionController', () => {
});
describe('recursionLimit resolution', () => {
it('threads the OpenAI parent message id through both MCP execution bodies', async () => {
const { validateRequest, createRun, initializeAgent } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'conversation-123',
parent_message_id: 'parent-123',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' });
await OpenAIChatCompletionController(req, res);
expect(initializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
expect.anything(),
);
expect(createRun).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
);
expect(mockProcessStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
configurable: expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
}),
expect.anything(),
);
});
it('does not synthesize an MCP parent for a continuation that omits it', async () => {
const { validateRequest, initializeAgent } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'conversation-123',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' });
await OpenAIChatCompletionController(req, res);
const requestBody = initializeAgent.mock.calls.at(-1)[0].requestBody;
expect(requestBody).toEqual({
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
});
expect(requestBody).not.toHaveProperty('parentMessageId');
});
it('should pass resolveRecursionLimit result to processStream config', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
resolveRecursionLimit.mockReturnValueOnce(75);

View file

@ -83,6 +83,11 @@ jest.mock('@librechat/api', () => ({
getAgentStartupTelemetry: jest.fn(() => undefined),
acceptAgentStartupTelemetry: jest.fn(),
isUnpersistedPreliminaryParent: jest.fn(async () => false),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
parentMessageId,
}),
}));
jest.mock('~/server/cleanup', () => ({

View file

@ -195,6 +195,11 @@ jest.mock('@librechat/api', () => ({
return messages.length === 0;
},
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
parentMessageId,
}),
}));
jest.mock('~/server/cleanup', () => ({
@ -383,6 +388,35 @@ describe('ResumableAgentController resume metadata', () => {
},
);
it.each(['overrideUserMessageId', 'overrideConvoId'])(
'rejects a non-string %s before admission',
async (field) => {
const req = {
user: { id: 'user-123' },
body: {
text: 'Invalid override identity',
messageId: 'user-message',
clientRequestId: 'override-request',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
[field]: { malformed: true },
},
config: {},
};
const res = { json: jest.fn(), status: jest.fn(() => res) };
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'INVALID_OVERRIDE_ID' }),
);
expect(mockGenerationJobManager.claimGeneration).not.toHaveBeenCalled();
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
},
);
it.each([
['empty recovery id', { clientRequestId: 'steer-recovery:' }],
['regenerate', { isRegenerate: true }],
@ -695,9 +729,14 @@ describe('ResumableAgentController resume metadata', () => {
preemptCapable: true,
agent_id: undefined,
isTemporary: true,
responseMessageId: 'follow-up-user_',
responseMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
mcpRequestBody: {
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
conversationId,
parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
},
userMessage: {
messageId: 'follow-up-user',
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
parentMessageId: 'original-response',
conversationId,
text: 'Check Google Workspace availability.',
@ -1023,6 +1062,89 @@ describe('ResumableAgentController resume metadata', () => {
);
});
it('preallocates response-scoped MCP identities before native Agent initialization', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use request-scoped headers.',
messageId: 'incoming-client-message',
parentMessageId: 'previous-response',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
expect(initializeClient).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
conversationId: 'conversation-123',
parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
},
}),
);
const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(jobOptions.initialMetadata.responseMessageId).toBe(requestBody.messageId);
expect(jobOptions.initialMetadata.userMessage.messageId).toBe(requestBody.parentMessageId);
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
expect(requestBody.messageId).not.toBe(req.body.messageId);
});
it('uses the effective overridden conversation in the MCP request body', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Continue in the overridden conversation.',
messageId: 'incoming-client-message',
parentMessageId: 'previous-response',
conversationId: 'source-conversation',
overrideConvoId: 'overridden-conversation__0',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(requestBody.conversationId).toBe('overridden-conversation');
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
});
it('preallocates the replacement response as the MCP parent for edited content', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Edited response text.',
messageId: 'existing-user-message',
responseMessageId: 'existing-response-message',
parentMessageId: 'previous-response',
overrideParentMessageId: 'existing-user-message',
editedContent: { index: 0, type: 'text', text: 'Edited response text.' },
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(requestBody.messageId).toMatch(/^[0-9a-f-]{36}$/);
expect(requestBody.parentMessageId).toBe(requestBody.messageId);
expect(requestBody.messageId).not.toBe('existing-response-message');
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
});
it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));

View file

@ -122,6 +122,13 @@ jest.mock('@librechat/api', () => ({
buildToolSet: jest.fn().mockReturnValue(new Set()),
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
...(parentMessageId !== undefined && {
parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000',
}),
}),
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args),
buildAgentContextAttachmentsByAgentId: (...args) =>
@ -547,6 +554,15 @@ describe('createResponse controller', () => {
expect(mockCreateAgentRunEnvelope.mock.invocationCallOrder[0]).toBeLessThan(
initializeAgent.mock.invocationCallOrder[0],
);
expect(initializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: 'resp_mock-123',
conversationId: expect.any(String),
},
}),
expect.anything(),
);
expect(req.body).not.toBe(requestBody);
expect(req.body).toEqual(requestBody);
expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain(
@ -734,7 +750,10 @@ describe('createResponse controller', () => {
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['file_search'], 'agent-123');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
}),
);
});
});

View file

@ -103,6 +103,11 @@ jest.mock('@librechat/api', () => ({
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
isSteerPreemptSupported: jest.fn(() => true),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
parentMessageId,
}),
}));
jest.mock('~/models', () => ({
@ -292,7 +297,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
});
mockAddTitle = jest.fn().mockResolvedValue(undefined);
mockInitializeClient = jest.fn(async ({ req, checkpointNamespace }) => {
mockInitializeClient = jest.fn(async ({ req, checkpointNamespace, requestBody }) => {
// Capture the request state the controller seeds BEFORE reconstruction.
capturedInit = {
parentMessageId: req.body.parentMessageId,
@ -301,6 +306,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
conversationCreatedAt: req.conversationCreatedAt,
timezone: req.body.timezone,
checkpointNamespace,
requestBody,
};
return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } };
});
@ -1195,6 +1201,11 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
// initializeAgent scopes thread files off req.body.parentMessageId, seeded
// from the paused user message's parent before initializeClient runs.
expect(capturedInit.parentMessageId).toBe(THREAD_PARENT_ID);
expect(capturedInit.requestBody).toEqual({
messageId: RESPONSE_MSG_ID,
conversationId: CONVO_ID,
parentMessageId: USER_MSG_ID,
});
expect(mockInitializeClient).toHaveBeenCalledTimes(1);
const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
@ -1206,6 +1217,23 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
);
});
it('reuses the persisted MCP identity for edited and overridden turns', async () => {
const persistedMCPRequestBody = {
messageId: RESPONSE_MSG_ID,
conversationId: 'overridden-conversation',
parentMessageId: RESPONSE_MSG_ID,
};
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { mcpRequestBody: persistedMCPRequestBody } }),
);
await post(approveBody());
await settled;
await flush();
expect(capturedInit.requestBody).toBe(persistedMCPRequestBody);
});
it('reuses the persisted generation checkpoint namespace and keeps legacy fallback explicit', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { checkpointNamespace: 'generation-1000' } }),

View file

@ -60,6 +60,7 @@ const {
createActivityLabelWiring,
createActivityPhaseWiring,
createReasoningLabelHostWiring,
createMCPRuntimeRequestBody,
generateReasoningLabelRevision,
getLabelUsageSequenceSeed,
createAssistantPhaseStampingHandlers,
@ -2952,11 +2953,13 @@ 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,
},
requestBody:
this.options.mcpRequestBody ??
createMCPRuntimeRequestBody({
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
}),
user: createSafeUser(this.options.req.user),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent),
@ -3541,11 +3544,13 @@ 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,
},
requestBody:
this.options.mcpRequestBody ??
createMCPRuntimeRequestBody({
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
}),
user: createSafeUser(this.options.req.user),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent),

View file

@ -20,6 +20,7 @@ const {
buildAgentContextAttachmentsByAgentId,
AgentRunEnvelopeError,
createAgentRunEnvelope,
createMCPRuntimeRequestBody,
loadSkillStates,
sendFinalChunk,
createSafeUser,
@ -97,6 +98,7 @@ function createToolLoader(signal, definitionsOnly = true) {
provider,
tool_options,
tool_resources,
requestBody,
codeExecutionContext,
accessibleMcpServerNames,
}) {
@ -107,6 +109,7 @@ function createToolLoader(signal, definitionsOnly = true) {
res,
agent,
signal,
requestBody,
tool_resources,
codeExecutionContext,
agentResourceType: ResourceType.REMOTE_AGENT,
@ -255,6 +258,17 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
const conversationId = request.conversation_id ?? nanoid();
const parentMessageId = request.parent_message_id ?? null;
let mcpParentMessageId;
if (typeof request.parent_message_id === 'string' && request.parent_message_id.trim() !== '') {
mcpParentMessageId = request.parent_message_id;
} else if (request.conversation_id == null) {
mcpParentMessageId = null;
}
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: responseId,
conversationId,
parentMessageId: mcpParentMessageId,
});
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
const allowedProviders = new Set(agentsEConfig?.allowedProviders);
@ -347,6 +361,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
requestFiles: [],
conversationId,
parentMessageId,
requestBody: mcpRequestBody,
agent,
endpointOption,
allowedProviders,
@ -414,6 +429,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
requestFiles: [],
conversationId,
parentMessageId,
requestBody: mcpRequestBody,
resourceType: ResourceType.REMOTE_AGENT,
computeAccessibleSkillIds: (handoffAgent) =>
resolveAgentScopedSkillIds({
@ -553,6 +569,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
@ -841,10 +858,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
appConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
user: { id: userId },
tenantId: principal.tenantId,
/** Bills subagent child-run model calls (reported outside the
@ -862,10 +876,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, agent),

View file

@ -28,6 +28,7 @@ const {
buildRecoveredSteerPayload,
deleteAgentCheckpoint,
getAttachmentTitleText,
createMCPRuntimeRequestBody,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
@ -96,18 +97,6 @@ async function attachConversationCreatedAt(req, conversationId, conversationAnch
}
}
function getPreliminaryResponseMessageId({ messageId, responseMessageId }) {
if (typeof responseMessageId === 'string' && responseMessageId.length > 0) {
return responseMessageId;
}
if (typeof messageId !== 'string' || messageId.length === 0) {
return null;
}
return `${messageId.replace(/_+$/, '')}_`;
}
function getPreliminaryUserMessage(
{ messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills },
conversationId,
@ -382,6 +371,23 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
);
}
const clientRequestId = rawClientRequestId;
const rawOverrideUserMessageId = req.body?.overrideUserMessageId;
const rawOverrideConversationId = req.body?.overrideConvoId;
if (
(rawOverrideUserMessageId != null && typeof rawOverrideUserMessageId !== 'string') ||
(rawOverrideConversationId != null && typeof rawOverrideConversationId !== 'string')
) {
startupTelemetry?.end('rejected');
return sendGenerationJson(
res,
400,
{
code: 'INVALID_OVERRIDE_ID',
error: 'overrideUserMessageId and overrideConvoId must be strings.',
},
generationProtocolVersion,
);
}
const rawExpectedPredecessorCreatedAt = req.body?.expectedPredecessorCreatedAt;
if (
rawExpectedPredecessorCreatedAt != null &&
@ -426,7 +432,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}
const recoveredSteerId = explicitRecoveredSteerId ?? legacyRecoveredSteerId;
const isRecoveredSteerRequest = recoveredSteerId != null;
const recoveryUserMessageId = req.body?.overrideUserMessageId;
const recoveryUserMessageId = rawOverrideUserMessageId;
const recoveredSteerPayload = isRecoveredSteerRequest
? buildRecoveredSteerPayload(text, req.body?.files)
: undefined;
@ -985,6 +991,34 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}
startupTelemetry?.mark('request_admitted');
/** Allocate the turn identities before Agent initialization. Request-scoped
* MCP transports resolve BODY placeholders while tools are discovered, so
* discovery and graph execution must receive the same response-scoped body.
* BaseClient otherwise allocates these IDs later in `sendMessage`, after MCP
* connections already exist. */
const overrideUserMessageId = rawOverrideUserMessageId
? rawOverrideUserMessageId.split(Constants.COMMON_DIVIDER)[0]
: undefined;
const preallocatedUserMessageId =
overrideUserMessageId ?? overrideParentMessageId ?? crypto.randomUUID();
const overrideConversationId = rawOverrideConversationId
? rawOverrideConversationId.split(Constants.COMMON_DIVIDER)[0]
: undefined;
const effectiveConversationId = overrideConversationId ?? conversationId;
let preallocatedResponseMessageId = editedResponseMessageId ?? crypto.randomUUID();
if (
(editedContent != null && !isContinued) ||
(isRegenerate && preallocatedResponseMessageId.endsWith('_'))
) {
preallocatedResponseMessageId = crypto.randomUUID();
}
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: preallocatedResponseMessageId,
conversationId: effectiveConversationId,
parentMessageId:
editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId,
});
let client = null;
let jobCreatedAt;
let providerExecutionId;
@ -1022,8 +1056,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const endpointIconURL = getEndpointIconURL(req, endpointOption);
const responseModel = getAgentResponseModel(req, endpointOption);
const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
const preliminaryUserMessage = getPreliminaryUserMessage(
{ ...req.body, messageId: preallocatedUserMessageId },
conversationId,
);
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
startupTelemetry,
...(recoveredSteerId && { recoveredSteerId }),
@ -1062,7 +1098,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
...(req._isManualScheduledFire === true && { scheduleManual: true }),
}
: {}),
responseMessageId: preliminaryResponseMessageId,
responseMessageId: preallocatedResponseMessageId,
mcpRequestBody,
userMessage: preliminaryUserMessage,
},
});
@ -1246,6 +1283,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
signal: job.abortController.signal,
jobCreatedAt,
checkpointNamespace: job.metadata?.checkpointNamespace,
requestBody: mcpRequestBody,
});
startupTelemetry?.mark('client_initialized');
client = result.client;
@ -1550,6 +1588,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
beforeResponsePersistence: claimBeforeResponsePersistence,
userMCPAuthMap: result.userMCPAuthMap,
responseMessageId: editedResponseMessageId,
preallocatedUserMessageId,
preallocatedResponseMessageId,
progressOptions: {
res: {
write: () => true,

View file

@ -16,6 +16,7 @@ const {
buildToolSet,
AgentRunEnvelopeError,
createAgentRunEnvelope,
createMCPRuntimeRequestBody,
buildAgentScopedContext,
buildInlineMemoryContext,
buildAgentContextAttachmentsByAgentId,
@ -107,6 +108,7 @@ function createToolLoader(signal, definitionsOnly = true) {
provider,
tool_options,
tool_resources,
requestBody,
codeExecutionContext,
accessibleMcpServerNames,
}) {
@ -117,6 +119,7 @@ function createToolLoader(signal, definitionsOnly = true) {
res,
agent,
signal,
requestBody,
tool_resources,
codeExecutionContext,
agentResourceType: ResourceType.REMOTE_AGENT,
@ -389,6 +392,7 @@ const executeResponse = async (envelope, { req, res }) => {
const conversationId = request.previous_response_id ?? uuidv4();
const parentMessageId = null;
const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, conversationId });
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
// Build allowed providers set
@ -482,6 +486,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestFiles: [],
conversationId,
parentMessageId,
requestBody: mcpRequestBody,
agent,
endpointOption,
allowedProviders,
@ -549,6 +554,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestFiles: [],
conversationId,
parentMessageId,
requestBody: mcpRequestBody,
resourceType: ResourceType.REMOTE_AGENT,
computeAccessibleSkillIds: (handoffAgent) =>
resolveAgentScopedSkillIds({
@ -800,6 +806,7 @@ const executeResponse = async (envelope, { req, res }) => {
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
@ -867,10 +874,7 @@ const executeResponse = async (envelope, { req, res }) => {
signal: abortController.signal,
customHandlers: handlers,
initialSessions,
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
user: { id: userId },
tenantId: principal.tenantId,
/** Bills subagent child-run model calls (reported outside the
@ -893,10 +897,7 @@ const executeResponse = async (envelope, { req, res }) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
@ -992,6 +993,7 @@ const executeResponse = async (envelope, { req, res }) => {
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
@ -1057,10 +1059,7 @@ const executeResponse = async (envelope, { req, res }) => {
signal: abortController.signal,
customHandlers: handlers,
initialSessions,
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
user: { id: userId },
tenantId: principal.tenantId,
/** Bills subagent child-run model calls (reported outside the
@ -1082,10 +1081,7 @@ const executeResponse = async (envelope, { req, res }) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
requestBody: mcpRequestBody,
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,

View file

@ -24,6 +24,7 @@ const {
isSteerPreemptSupported,
isStopConfirmed,
toPendingSteer,
createMCPRuntimeRequestBody,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
@ -1190,6 +1191,13 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
signal: job.abortController.signal,
jobCreatedAt: job.createdAt,
checkpointNamespace,
requestBody:
job.metadata.mcpRequestBody ??
createMCPRuntimeRequestBody({
messageId: job.metadata.responseMessageId,
conversationId: streamId,
parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT,
}),
});
client = result.client;

View file

@ -2753,6 +2753,33 @@ describe('MCP Routes', () => {
expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
});
it('preserves request-scoped metadata when an individual status check fails', async () => {
getMCPSetupData.mockResolvedValue({
mcpConfig: {
server1: {
source: 'config',
headers: { 'X-Message': '{{LIBRECHAT_BODY_MESSAGEID}}' },
customUserVars: { API_KEY: { title: 'API key' } },
},
},
appConnections: new Map(),
userConnections: new Map(),
oauthServers: new Set(),
});
getServerConnectionStatus.mockRejectedValueOnce(new Error('status unavailable'));
const response = await request(app).get('/api/mcp/connection/status');
expect(response.status).toBe(200);
expect(response.body.connectionStatus.server1).toEqual(
expect.objectContaining({
connectionState: 'error',
requestScoped: true,
configurationState: 'needs_configuration',
}),
);
});
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database error'));

View file

@ -23,6 +23,7 @@ const {
OAUTH_SESSION_COOKIE,
mcpConfig: mcpSettings,
getServerCustomUserVars,
hasCustomUserVars,
requiresEphemeralUserConnection,
} = require('@librechat/api');
const {
@ -926,6 +927,9 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
{
connectionState: 'error',
requiresOAuth: oauthServers.has(serverName),
...(requiresEphemeralUserConnection(config) && { requestScoped: true }),
...(requiresEphemeralUserConnection(config) &&
hasCustomUserVars(config) && { configurationState: 'needs_configuration' }),
authorizationState: oauthServers.has(serverName) ? 'error' : 'not_required',
error: message,
},
@ -987,6 +991,8 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
serverName,
connectionStatus: serverStatus.connectionState,
requiresOAuth: serverStatus.requiresOAuth,
requestScoped: serverStatus.requestScoped,
configurationState: serverStatus.configurationState,
authorizationState: serverStatus.authorizationState,
});
} catch (error) {

View file

@ -42,6 +42,7 @@ const loadAddedAgent = (params) =>
* @param {Array} params.requestFiles - Request files
* @param {string} params.conversationId - The conversation ID
* @param {string} [params.parentMessageId] - The parent message ID for thread filtering
* @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody]
* @param {Set} params.allowedProviders - Set of allowed providers
* @param {Map} params.agentConfigs - Map of agent configs to add to
* @param {string} params.primaryAgentId - The primary agent ID
@ -70,6 +71,7 @@ const processAddedConvo = async ({
requestFiles,
conversationId,
parentMessageId,
requestBody,
allowedProviders,
agentConfigs,
primaryAgentId,
@ -170,6 +172,7 @@ const processAddedConvo = async ({
requestFiles,
conversationId,
parentMessageId,
requestBody,
agent: addedAgent,
endpointOption,
allowedProviders,

View file

@ -102,6 +102,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
provider,
tool_options,
tool_resources,
requestBody,
codeExecutionContext,
accessibleMcpServerNames,
}) {
@ -114,6 +115,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
signal,
streamId,
jobCreatedAt,
requestBody,
tool_resources,
codeExecutionContext,
definitionsOnly,
@ -137,6 +139,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
* @param {Object} params.endpointOption
* @param {number} [params.jobCreatedAt]
* @param {string} [params.checkpointNamespace] Immutable saver-level generation scope
* @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody]
*/
const initializeClient = async ({
req,
@ -145,6 +148,7 @@ const initializeClient = async ({
endpointOption,
jobCreatedAt,
checkpointNamespace,
requestBody,
}) => {
if (!endpointOption) {
throw new Error('Endpoint option not provided');
@ -154,6 +158,7 @@ const initializeClient = async ({
* that trusted document for child-thread execution policy; resume and direct
* callers fall back to the same owner-scoped lookup. */
const conversationId = req.body?.conversationId;
const runtimeRequestBody = requestBody ?? req.body;
let requestConversationPromise = Promise.resolve(null);
if (Object.prototype.hasOwnProperty.call(req, 'resolvedConversation')) {
requestConversationPromise = Promise.resolve(req.resolvedConversation);
@ -334,6 +339,7 @@ const initializeClient = async ({
signal,
streamId,
conversationId,
requestBody: runtimeRequestBody,
toolNames,
agent: ctx.agent,
toolRegistry: ctx.toolRegistry,
@ -496,6 +502,7 @@ const initializeClient = async ({
requestFiles,
conversationId,
parentMessageId,
requestBody: runtimeRequestBody,
agent: primaryAgent,
endpointOption,
allowedProviders,
@ -561,6 +568,7 @@ const initializeClient = async ({
requestFiles,
conversationId,
parentMessageId,
requestBody: runtimeRequestBody,
computeAccessibleSkillIds: (agent) =>
resolveAgentScopedSkillIds({
agent,
@ -651,6 +659,7 @@ const initializeClient = async ({
userMCPAuthMap,
conversationId,
parentMessageId,
requestBody: runtimeRequestBody,
allowedProviders,
primaryAgentId: primaryConfig.id,
accessibleSkillIds,
@ -940,6 +949,7 @@ const initializeClient = async ({
requestFiles,
conversationId,
parentMessageId,
requestBody: runtimeRequestBody,
endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents },
allowedProviders,
accessibleSkillIds: scopedSkillIds,
@ -1417,6 +1427,7 @@ const initializeClient = async ({
toolInputValidationErrors,
jobCreatedAt,
checkpointNamespace,
mcpRequestBody: runtimeRequestBody,
});
if (streamId) {

View file

@ -617,7 +617,7 @@ describe('initializeClient — subagent loading', () => {
agentClientArgs = undefined;
capturedToolExecuteOptions = undefined;
mockLoadToolsForExecution.mockReset();
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] });
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} });
testUser = await User.create({
email: 'subagent@example.com',
@ -741,6 +741,32 @@ describe('initializeClient — subagent loading', () => {
});
});
it('uses one normalized MCP body for discovery, deferred execution, and AgentClient', async () => {
const requestBody = Object.freeze({
messageId: 'response-message',
conversationId: 'conv_sub',
parentMessageId: 'user-message',
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig({}));
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} });
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
requestBody,
});
expect(mockInitializeAgent.mock.calls[0][0].requestBody).toBe(requestBody);
expect(agentClientArgs.mcpRequestBody).toBe(requestBody);
await capturedToolExecuteOptions.loadTools([], PRIMARY_ID);
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
expect.objectContaining({ requestBody }),
);
});
it('keeps an existing detached task controllable after subagent config is disabled', async () => {
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({

View file

@ -29,6 +29,7 @@ const {
buildMCPAuthRunStepDeltaEvent,
buildMCPAuthRunStepEndDeltaEvent,
isUserSourced,
hasCustomUserVars,
checkAccessWithRequestCache,
getMissingCustomUserVars,
getUserMCPAuthMap,
@ -1461,6 +1462,19 @@ async function hasDurableMCPAuthorization(userId, serverName, config, runtimeCon
});
}
async function getMCPUserConfigurationState(serverName, config, runtimeContext = {}) {
if (!hasCustomUserVars(config)) {
return undefined;
}
const userMCPAuthMap =
runtimeContext.userMCPAuthMap ?? (await runtimeContext.loadUserMCPAuthMap?.());
const customUserVars = getServerCustomUserVars(userMCPAuthMap, serverName);
return getMissingCustomUserVars(config, customUserVars).length > 0
? 'needs_configuration'
: 'configured';
}
function canDetectMCPRuntimeOAuth(config) {
return config.requiresOAuth == null && config.apiKey == null && hasRuntimeUrlPlaceholders(config);
}
@ -1474,7 +1488,7 @@ function canDetectMCPRuntimeOAuth(config) {
* @param {Map<string, import('@librechat/api').MCPConnection>} userConnections - User-level connections
* @param {Set} oauthServers - Set of OAuth servers
* @param {{ user?: Partial<IUser>, userMCPAuthMap?: Record<string, Record<string, string>>, loadUserMCPAuthMap?: () => Promise<Record<string, Record<string, string>> | undefined>, loadMCPAllowlists?: () => Promise<{ allowedDomains?: string[] | null, allowedAddresses?: string[] | null }> }} [runtimeContext]
* @returns {Object} Object containing requiresOAuth and connectionState
* @returns {Object} Object containing requiresOAuth, requestScoped, connectionState, and authorizationState
*/
async function getServerConnectionStatus(
userId,
@ -1491,6 +1505,10 @@ async function getServerConnectionStatus(
const liveConnectionOAuth = connection?.usesOAuth?.() === true;
const runtimeOAuthCandidate = canDetectMCPRuntimeOAuth(config);
const effectiveOAuth = configuredOAuth || liveConnectionOAuth;
const requestScoped = requiresEphemeralUserConnection(config);
const configurationState = requestScoped
? await getMCPUserConfigurationState(serverName, config, runtimeContext)
: undefined;
const baseConnectionState = isStaleOrDoNotExist
? 'disconnected'
@ -1535,6 +1553,8 @@ async function getServerConnectionStatus(
return {
requiresOAuth,
...(requestScoped && { requestScoped: true }),
...(configurationState && { configurationState }),
connectionState: finalConnectionState,
authorizationState,
};

View file

@ -546,6 +546,60 @@ describe('tests for the new helper functions used by the MCP connection status e
});
});
it('marks BODY placeholder servers as request-scoped while they are idle', async () => {
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
{
...mockConfig,
source: 'yaml',
headers: { 'X-Parent-Message': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' },
},
new Map(),
new Map(),
new Set(),
);
expect(result).toEqual({
requiresOAuth: false,
requestScoped: true,
connectionState: 'disconnected',
authorizationState: 'not_required',
});
});
it('reports whether custom variables are configured for request-scoped servers', async () => {
const config = {
...mockConfig,
source: 'yaml',
headers: { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
customUserVars: { API_KEY: { title: 'API key' } },
};
const connectionArgs = [new Map(), new Map(), new Set()];
const missing = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
...connectionArgs,
{ userMCPAuthMap: {} },
);
const configured = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
...connectionArgs,
{
userMCPAuthMap: {
[`${Constants.mcp_prefix}${mockServerName}`]: { API_KEY: 'secret' },
},
},
);
expect(missing.configurationState).toBe('needs_configuration');
expect(configured.configurationState).toBe('configured');
});
it('should prioritize app connection over user connection', async () => {
const appConnections = new Map([
[
@ -871,6 +925,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
requestScoped: true,
connectionState: 'connecting',
authorizationState: 'authorizing',
});

View file

@ -259,6 +259,7 @@ async function processRequiredActions(client, requiredActions) {
options: {
processFileURL,
req: client.req,
res: client.res,
uploadImageBuffer,
openAIApiKey: client.apiKey,
returnMetadata: true,
@ -565,6 +566,7 @@ const isBuiltInTool = (toolName) =>
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} [params.res] - The response object for SSE events
* @param {Object} params.agent - The agent configuration
* @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body
* @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route
* @param {string|null} [params.streamId] - Stream ID for resumable mode
* @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
@ -580,6 +582,7 @@ async function loadToolDefinitionsWrapper({
req,
res,
agent,
requestBody,
agentResourceType,
streamId = null,
jobCreatedAt,
@ -599,6 +602,7 @@ async function loadToolDefinitionsWrapper({
}
const appConfig = req.config;
const runtimeRequestBody = requestBody ?? req.body;
const hasExpectedMCPTools = agent.tools.some(isExpectedMCPTool);
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
@ -620,7 +624,7 @@ async function loadToolDefinitionsWrapper({
environment: agent.stateful_code_environment,
userId: req.user.id,
agentId: agent.id,
conversationId: req.body?.conversationId,
conversationId: runtimeRequestBody?.conversationId,
});
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
const mcpPermissionContext = createMCPPermissionContext(req);
@ -927,7 +931,7 @@ async function loadToolDefinitionsWrapper({
serverName,
configServers,
userMCPAuthMap,
requestBody: req.body,
requestBody: runtimeRequestBody,
requestScopedConnections,
});
@ -954,7 +958,7 @@ async function loadToolDefinitionsWrapper({
serverName,
configServers,
userMCPAuthMap,
requestBody: req.body,
requestBody: runtimeRequestBody,
requestScopedConnections,
});
@ -1082,7 +1086,7 @@ async function loadToolDefinitionsWrapper({
configServers,
userMCPAuthMap,
flowManager,
requestBody: req.body,
requestBody: runtimeRequestBody,
returnOnOAuth: false,
oauthStart,
oauthEnd: createOAuthEndEmitter(serverName),
@ -1255,6 +1259,7 @@ async function loadToolDefinitionsWrapper({
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent configuration
* @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body
* @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} [params.tool_resources] - Tool resources
@ -1269,6 +1274,7 @@ async function loadAgentTools({
req,
res,
agent,
requestBody,
agentResourceType,
signal,
tool_resources,
@ -1285,6 +1291,7 @@ async function loadAgentTools({
req,
res,
agent,
requestBody,
agentResourceType,
streamId,
jobCreatedAt,
@ -1402,7 +1409,7 @@ async function loadAgentTools({
environment: agent.stateful_code_environment,
userId: req.user.id,
agentId: agent.id,
conversationId: req.body?.conversationId,
conversationId: requestBody?.conversationId ?? req.body?.conversationId,
});
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
@ -1415,6 +1422,7 @@ async function loadAgentTools({
options: {
req,
res,
requestBody,
agentResourceType,
mcpServerContext,
jobCreatedAt,
@ -1676,6 +1684,7 @@ async function loadAgentTools({
* @param {ServerResponse} params.res - The response object
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} params.agent - The agent object
* @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body
* @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route
* @param {string[]} params.toolNames - Names of tools to load
* @param {Map} [params.toolRegistry] - Tool registry
@ -1695,6 +1704,7 @@ async function loadToolsForExecution({
res,
signal,
agent,
requestBody,
agentResourceType,
toolNames,
toolRegistry,
@ -1712,8 +1722,13 @@ async function loadToolsForExecution({
}) {
const appConfig = req.config;
const allLoadedTools = [];
const runtimeRequestBody = requestBody ?? req.body;
const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res);
const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections };
const configurable = {
userMCPAuthMap,
requestBody: runtimeRequestBody,
requestScopedConnections: mcpRequestScopedConnections,
};
/** Per-agent set of tools that received the injected `run_in_background`
* param; the event-driven executor gates background dispatch and the
* `check_background_task` poll tool on this reliable per-agent channel. */
@ -1770,7 +1785,7 @@ async function loadToolsForExecution({
environment: agent?.stateful_code_environment,
userId: req.user.id,
agentId: agent?.id,
conversationId: conversationId ?? req.body?.conversationId,
conversationId: conversationId ?? runtimeRequestBody?.conversationId,
});
configurable.codeExecutionContext = codeExecutionContext;
@ -1900,6 +1915,7 @@ async function loadToolsForExecution({
options: {
req,
res,
requestBody: runtimeRequestBody,
agentResourceType,
jobCreatedAt,
tool_resources,

View file

@ -1429,6 +1429,33 @@ describe('ToolService - Action Capability Gating', () => {
);
});
it('threads the normalized MCP body through deferred tool loading', async () => {
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
const req = createMockReq(capabilities);
const requestBody = {
messageId: 'response-1',
conversationId: 'conversation-1',
parentMessageId: 'parent-1',
};
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
const result = await loadToolsForExecution({
req,
res: {},
requestBody,
agent: { id: 'agent_123', tools: [Tools.web_search] },
toolNames: [Tools.web_search],
actionsEnabled: false,
});
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ requestBody }),
}),
);
expect(result.configurable.requestBody).toBe(requestBody);
});
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
const regularTool = Tools.web_search;
@ -2147,6 +2174,11 @@ describe('ToolService - Action Capability Gating', () => {
// zodSchema, name, and description for assistants API"), so key
// resolution assertions off the request builder path instead.
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ res: client.res }),
}),
);
const builderPaths = mockCreateActionTool.mock.calls.map((c) => c[0].requestBuilder?.path);
expect(builderPaths).toEqual(expect.arrayContaining(['/echo', '/items']));
// Each call must carry a distinct builder — guards against the bug