mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🪡 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:
parent
f02ce63d57
commit
8ae94afa91
63 changed files with 1500 additions and 199 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' } }),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue