mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +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
|
|
@ -1,6 +1,7 @@
|
|||
# Domain language
|
||||
|
||||
- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
|
||||
- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition.
|
||||
- **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat.
|
||||
- **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence.
|
||||
- **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store.
|
||||
|
|
|
|||
|
|
@ -347,16 +347,22 @@ class BaseClient {
|
|||
const conversationId = requestConvoId ?? crypto.randomUUID();
|
||||
const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
|
||||
const userMessageId =
|
||||
overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
|
||||
let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
|
||||
opts.preallocatedUserMessageId ??
|
||||
overrideUserMessageId ??
|
||||
opts.overrideParentMessageId ??
|
||||
crypto.randomUUID();
|
||||
let responseMessageId =
|
||||
opts.responseMessageId ?? opts.preallocatedResponseMessageId ?? crypto.randomUUID();
|
||||
let head = isEdited ? responseMessageId : parentMessageId;
|
||||
this.currentMessages = (await this.loadHistory(conversationId, head)) ?? [];
|
||||
this.conversationId = conversationId;
|
||||
|
||||
if (isEdited && !isContinued) {
|
||||
responseMessageId = crypto.randomUUID();
|
||||
responseMessageId = opts.preallocatedResponseMessageId ?? crypto.randomUUID();
|
||||
head = responseMessageId;
|
||||
this.currentMessages[this.currentMessages.length - 1].messageId = head;
|
||||
} else if (opts.preallocatedResponseMessageId != null) {
|
||||
responseMessageId = opts.preallocatedResponseMessageId;
|
||||
}
|
||||
|
||||
if (opts.isRegenerate && responseMessageId.endsWith('_')) {
|
||||
|
|
|
|||
|
|
@ -691,6 +691,21 @@ describe('BaseClient', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('honors response and user message IDs preallocated before initialization', async () => {
|
||||
TestClient = initializeFakeClient(apiKey, options, messageHistory);
|
||||
|
||||
const result = await TestClient.handleStartMethods('request-scoped MCP', {
|
||||
conversationId,
|
||||
parentMessageId: '3',
|
||||
preallocatedUserMessageId: 'preallocated-user',
|
||||
preallocatedResponseMessageId: 'preallocated-response',
|
||||
});
|
||||
|
||||
expect(result.userMessage.messageId).toBe('preallocated-user');
|
||||
expect(result.responseMessageId).toBe('preallocated-response');
|
||||
expect(TestClient.responseMessageId).toBe('preallocated-response');
|
||||
});
|
||||
|
||||
it('applies edited reasoning content from its typed payload before regeneration', async () => {
|
||||
const responseMessageId = 'response-with-reasoning';
|
||||
const newHistory = [
|
||||
|
|
|
|||
|
|
@ -592,7 +592,7 @@ const loadTools = async ({
|
|||
user: safeUser,
|
||||
userMCPAuthMap,
|
||||
configServers,
|
||||
requestBody: options.req?.body,
|
||||
requestBody: options.requestBody ?? options.req?.body,
|
||||
requestScopedConnections,
|
||||
res: options.res,
|
||||
streamId: options.req?._resumableStreamId || null,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
useMCPConnectionStatus,
|
||||
useMCPServerManager,
|
||||
} from '~/hooks';
|
||||
import { isMCPServerReadyForAgent } from '~/components/MCP/mcpServerUtils';
|
||||
import { Panel, isEphemeralAgent } from '~/common';
|
||||
|
||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||
|
|
@ -71,6 +72,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
for (const [serverName, serverData] of Object.entries(mcpData.servers)) {
|
||||
// Get title and description from config with fallbacks
|
||||
const serverConfig = availableMCPServersMap?.[serverName];
|
||||
const serverStatus = connectionStatus?.[serverName];
|
||||
const displayName = serverConfig?.title || serverName;
|
||||
const displayDescription =
|
||||
serverConfig?.description || `${localize('com_ui_tool_collection_prefix')} ${serverName}`;
|
||||
|
|
@ -98,7 +100,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
serverName,
|
||||
tools,
|
||||
isConfigured: configuredServers.has(serverName),
|
||||
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
|
||||
isConnected: serverStatus?.connectionState === 'connected',
|
||||
isReadyForAgent: isMCPServerReadyForAgent(
|
||||
serverStatus,
|
||||
serverConfig?.requestScoped === true,
|
||||
Object.keys(serverConfig?.customUserVars ?? {}).length > 0,
|
||||
),
|
||||
requestScoped: serverConfig?.requestScoped,
|
||||
metadata,
|
||||
consumeOnly: serverConfig?.consumeOnly,
|
||||
|
|
@ -113,6 +120,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
}
|
||||
// Get title and description from config with fallbacks
|
||||
const serverConfig = availableMCPServersMap?.[mcpServerName];
|
||||
const serverStatus = connectionStatus?.[mcpServerName];
|
||||
const displayName = serverConfig?.title || mcpServerName;
|
||||
const displayDescription =
|
||||
serverConfig?.description ||
|
||||
|
|
@ -130,7 +138,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
metadata,
|
||||
isConfigured: true,
|
||||
serverName: mcpServerName,
|
||||
isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected',
|
||||
isConnected: serverStatus?.connectionState === 'connected',
|
||||
isReadyForAgent: isMCPServerReadyForAgent(
|
||||
serverStatus,
|
||||
serverConfig?.requestScoped === true,
|
||||
Object.keys(serverConfig?.customUserVars ?? {}).length > 0,
|
||||
),
|
||||
requestScoped: serverConfig?.requestScoped,
|
||||
consumeOnly: serverConfig?.consumeOnly,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ export interface MCPServerInfo {
|
|||
tools: t.AgentToolType[];
|
||||
isConfigured: boolean;
|
||||
isConnected: boolean;
|
||||
/** True when the server can be attached to an agent, even if its transport is request-scoped. */
|
||||
isReadyForAgent?: boolean;
|
||||
/** True when tools can only be discovered with live chat request fields. */
|
||||
requestScoped?: boolean;
|
||||
consumeOnly?: boolean;
|
||||
|
|
|
|||
71
client/src/components/MCP/MCPServerStatusIcon.spec.tsx
Normal file
71
client/src/components/MCP/MCPServerStatusIcon.spec.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import MCPServerStatusIcon from './MCPServerStatusIcon';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: (props: React.ComponentProps<'span'>) => <span {...props} />,
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { variant?: string; size?: string }) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const requestScopedStatus: MCPServerStatus = {
|
||||
connectionState: 'disconnected',
|
||||
authorizationState: 'not_required',
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
configurationState: 'needs_configuration',
|
||||
};
|
||||
|
||||
describe('MCPServerStatusIcon', () => {
|
||||
it('shows Configure instead of Connect for idle request-scoped custom variables', () => {
|
||||
render(
|
||||
<MCPServerStatusIcon
|
||||
serverName="server"
|
||||
serverStatus={requestScopedStatus}
|
||||
isInitializing={false}
|
||||
canCancel={false}
|
||||
hasCustomUserVars={true}
|
||||
onConfigClick={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'com_nav_mcp_configure_server' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_nav_mcp_connect_server' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps idle request-scoped servers without custom variables actionless', () => {
|
||||
const { container } = render(
|
||||
<MCPServerStatusIcon
|
||||
serverName="server"
|
||||
serverStatus={requestScopedStatus}
|
||||
isInitializing={false}
|
||||
canCancel={false}
|
||||
hasCustomUserVars={false}
|
||||
onConfigClick={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -71,7 +71,7 @@ export default function MCPServerStatusIcon({
|
|||
return null;
|
||||
}
|
||||
|
||||
const { connectionState } = serverStatus;
|
||||
const { connectionState, requestScoped } = serverStatus;
|
||||
|
||||
// Connecting: show spinner, with cancel when an OAuth flow is pending.
|
||||
if (connectionState === 'connecting') {
|
||||
|
|
@ -89,6 +89,13 @@ export default function MCPServerStatusIcon({
|
|||
return <ConnectingSpinner serverName={serverName} />;
|
||||
}
|
||||
|
||||
// Request-scoped servers can only be connected while serving an MCP request.
|
||||
if ((connectionState === 'disconnected' || connectionState === 'error') && requestScoped) {
|
||||
return hasCustomUserVars ? (
|
||||
<ConfigureButton serverName={serverName} onConfigClick={onConfigClick} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
// Disconnected or Error: show connect button (PlugZap icon)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') {
|
||||
return <ConnectButton serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
|
|
@ -126,10 +133,12 @@ function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProp
|
|||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
let colorClass = 'bg-status-neutral';
|
||||
if (connectionState === 'connected') {
|
||||
colorClass = 'bg-status-success';
|
||||
} else if (connectionState === 'connecting') {
|
||||
if (connectionState === 'connecting') {
|
||||
colorClass = 'bg-status-info';
|
||||
} else if (serverStatus.requestScoped) {
|
||||
colorClass = 'bg-status-info';
|
||||
} else if (connectionState === 'connected') {
|
||||
colorClass = 'bg-status-success';
|
||||
} else if (connectionState === 'error') {
|
||||
colorClass = 'bg-status-error';
|
||||
} else if (connectionState === 'disconnected' && requiresOAuth) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import ServerInitializationSection from './ServerInitializationSection';
|
||||
|
||||
const mockInitializeServer = jest.fn();
|
||||
const mockConnectionStatus = jest.fn((): MCPServerStatus | undefined => undefined);
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useMCPConnectionStatus: () => ({
|
||||
connectionStatus: { server: mockConnectionStatus() },
|
||||
}),
|
||||
useMCPServerManager: () => ({
|
||||
getOAuthUrl: () => undefined,
|
||||
isCancellable: () => false,
|
||||
isInitializing: () => false,
|
||||
cancelOAuthFlow: jest.fn(),
|
||||
initializeServer: mockInitializeServer,
|
||||
availableMCPServers: [{ serverName: 'server' }],
|
||||
availableMCPServersMap: { server: { requestScoped: true } },
|
||||
revokeOAuthForServer: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: (props: React.ComponentProps<'span'>) => <span {...props} />,
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { variant?: string; size?: string }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ServerInitializationSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('offers deferred initialization after request-scoped custom variables are configured', () => {
|
||||
mockConnectionStatus.mockReturnValue({
|
||||
connectionState: 'disconnected',
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
configurationState: 'needs_configuration',
|
||||
});
|
||||
const { rerender } = render(
|
||||
<ServerInitializationSection
|
||||
serverName="server"
|
||||
requiresOAuth={false}
|
||||
hasCustomUserVars={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_mcp_initialize' })).not.toBeInTheDocument();
|
||||
|
||||
mockConnectionStatus.mockReturnValue({
|
||||
connectionState: 'disconnected',
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
configurationState: 'configured',
|
||||
});
|
||||
rerender(
|
||||
<ServerInitializationSection
|
||||
serverName="server"
|
||||
requiresOAuth={false}
|
||||
hasCustomUserVars={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_mcp_initialize' }));
|
||||
expect(mockInitializeServer).toHaveBeenCalledWith('server', false);
|
||||
});
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ export default function ServerInitializationSection({
|
|||
cancelOAuthFlow,
|
||||
initializeServer,
|
||||
availableMCPServers,
|
||||
availableMCPServersMap,
|
||||
revokeOAuthForServer,
|
||||
} = useMCPServerManager({ conversationId, storageContextKey });
|
||||
|
||||
|
|
@ -46,10 +47,23 @@ export default function ServerInitializationSection({
|
|||
const isServerInitializing = isInitializing(serverName);
|
||||
const serverOAuthUrl = getOAuthUrl(serverName);
|
||||
|
||||
const shouldShowReinit = isConnected && (requiresOAuth || hasCustomUserVars);
|
||||
const shouldShowInit = !isConnected && !serverOAuthUrl && !hasPendingOAuth;
|
||||
const requestScoped =
|
||||
serverStatus?.requestScoped === true ||
|
||||
availableMCPServersMap?.[serverName]?.requestScoped === true;
|
||||
const shouldShowReinit = isConnected && !requestScoped && (requiresOAuth || hasCustomUserVars);
|
||||
/** Saving custom variables makes an on-demand server ready, but it still
|
||||
* needs one explicit initialization attempt so callers waiting to attach the
|
||||
* runtime wildcard observe `connectionDeferred`. */
|
||||
const canDeferRequestScopedConnection =
|
||||
requestScoped && hasCustomUserVars && serverStatus?.configurationState === 'configured';
|
||||
const shouldShowInit =
|
||||
!isConnected &&
|
||||
(!requestScoped || canDeferRequestScopedConnection) &&
|
||||
!serverOAuthUrl &&
|
||||
!hasPendingOAuth;
|
||||
const shouldShowRevoke = requiresOAuth && revokeOAuthForServer != null;
|
||||
|
||||
if (!shouldShowReinit && !shouldShowInit && !serverOAuthUrl) {
|
||||
if (!shouldShowReinit && !shouldShowInit && !shouldShowRevoke && !serverOAuthUrl) {
|
||||
if (!hasPendingOAuth) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -114,27 +128,29 @@ export default function ServerInitializationSection({
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{requiresOAuth && revokeOAuthForServer && (
|
||||
{shouldShowRevoke && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => revokeOAuthForServer(serverName)}
|
||||
onClick={() => revokeOAuthForServer?.(serverName)}
|
||||
aria-label={localize('com_ui_revoke')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => initializeServer(serverName, false)}
|
||||
disabled={isServerInitializing}
|
||||
size={sidePanel ? 'sm' : 'default'}
|
||||
className="flex-1"
|
||||
>
|
||||
{icon}
|
||||
{buttonText}
|
||||
</Button>
|
||||
{(shouldShowReinit || shouldShowInit) && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => initializeServer(serverName, false)}
|
||||
disabled={isServerInitializing}
|
||||
size={sidePanel ? 'sm' : 'default'}
|
||||
className="flex-1"
|
||||
>
|
||||
{icon}
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
66
client/src/components/MCP/mcpServerUtils.spec.ts
Normal file
66
client/src/components/MCP/mcpServerUtils.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import { isMCPServerReadyForAgent, shouldShowActionButton } from './mcpServerUtils';
|
||||
|
||||
const status = (
|
||||
connectionState: MCPServerStatus['connectionState'],
|
||||
authorizationState: MCPServerStatus['authorizationState'],
|
||||
): MCPServerStatus => ({ connectionState, authorizationState, requiresOAuth: false });
|
||||
|
||||
describe('isMCPServerReadyForAgent', () => {
|
||||
it('treats an authorized idle request-scoped server as ready', () => {
|
||||
expect(isMCPServerReadyForAgent(status('disconnected', 'authorized'), true)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an idle request-scoped server without an auth requirement as ready', () => {
|
||||
expect(isMCPServerReadyForAgent(status('disconnected', 'not_required'), true)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps request-scoped servers gated while authorization is incomplete or failed', () => {
|
||||
expect(isMCPServerReadyForAgent(status('disconnected', 'needs_authorization'), true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isMCPServerReadyForAgent(status('error', 'error'), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('requires declared custom variables before an on-demand server is ready', () => {
|
||||
const missingConfiguration = {
|
||||
...status('disconnected', 'not_required'),
|
||||
configurationState: 'needs_configuration' as const,
|
||||
};
|
||||
const configured = {
|
||||
...missingConfiguration,
|
||||
configurationState: 'configured' as const,
|
||||
};
|
||||
|
||||
expect(isMCPServerReadyForAgent(missingConfiguration, true, true)).toBe(false);
|
||||
expect(isMCPServerReadyForAgent(configured, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a live connection for servers that are not request-scoped', () => {
|
||||
expect(isMCPServerReadyForAgent(status('disconnected', 'not_required'), false)).toBe(false);
|
||||
expect(isMCPServerReadyForAgent(status('connected', 'not_required'), false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowActionButton', () => {
|
||||
it('keeps configuration actionable for an idle request-scoped server', () => {
|
||||
const serverStatus: MCPServerStatus = {
|
||||
connectionState: 'disconnected',
|
||||
authorizationState: 'not_required',
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
configurationState: 'needs_configuration',
|
||||
};
|
||||
const baseProps = {
|
||||
serverName: 'server',
|
||||
serverStatus,
|
||||
isInitializing: false,
|
||||
canCancel: false,
|
||||
onCancel: jest.fn(),
|
||||
onConfigClick: jest.fn(),
|
||||
};
|
||||
|
||||
expect(shouldShowActionButton({ ...baseProps, hasCustomUserVars: true })).toBe(true);
|
||||
expect(shouldShowActionButton({ ...baseProps, hasCustomUserVars: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -64,7 +64,7 @@ export function getSelectedServerIcons(
|
|||
/**
|
||||
* Unified status color system following UX best practices:
|
||||
* - Green: Connected/Active (success)
|
||||
* - Blue: Connecting/In-progress (processing)
|
||||
* - Blue: Connecting/In-progress or request-scoped on-demand
|
||||
* - Amber: Needs user action (OAuth required, config missing)
|
||||
* - Gray: Disconnected/Inactive (neutral - server is simply off)
|
||||
* - Red: Error (failed, needs retry)
|
||||
|
|
@ -87,13 +87,17 @@ export function getStatusColor(
|
|||
return 'bg-status-neutral';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
const { connectionState, requiresOAuth, requestScoped } = status;
|
||||
|
||||
// Connecting: blue (in progress)
|
||||
if (connectionState === 'connecting') {
|
||||
return 'bg-status-info';
|
||||
}
|
||||
|
||||
if (requestScoped) {
|
||||
return 'bg-status-info';
|
||||
}
|
||||
|
||||
// Connected: green (success)
|
||||
if (connectionState === 'connected') {
|
||||
return 'bg-status-success';
|
||||
|
|
@ -131,7 +135,15 @@ export function getStatusTextKey(
|
|||
return 'com_nav_mcp_status_unknown';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
const { connectionState, requiresOAuth, requestScoped } = status;
|
||||
|
||||
if (connectionState === 'connecting') {
|
||||
return 'com_nav_mcp_status_connecting';
|
||||
}
|
||||
|
||||
if (requestScoped) {
|
||||
return 'com_nav_mcp_status_on_demand';
|
||||
}
|
||||
|
||||
// Special case: disconnected but needs OAuth shows different text
|
||||
if (connectionState === 'disconnected' && requiresOAuth) {
|
||||
|
|
@ -157,7 +169,9 @@ export function serverNeedsAction(
|
|||
_hasCustomUserVars?: boolean,
|
||||
): boolean {
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
const { connectionState, requiresOAuth, requestScoped } = serverStatus;
|
||||
|
||||
if (requestScoped && connectionState !== 'connecting') return false;
|
||||
|
||||
// Needs OAuth authentication
|
||||
if (connectionState === 'disconnected' && requiresOAuth) return true;
|
||||
|
|
@ -168,6 +182,31 @@ export function serverNeedsAction(
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-scoped servers are usable without an idle transport connection once
|
||||
* their authorization requirement is satisfied. Agent tooling uses this
|
||||
* readiness signal to attach the runtime wildcard instead of waiting for a
|
||||
* tool catalog that can only be discovered during a chat request.
|
||||
*/
|
||||
export function isMCPServerReadyForAgent(
|
||||
status: MCPServerStatus | undefined,
|
||||
requestScoped: boolean,
|
||||
hasCustomUserVars = false,
|
||||
): boolean {
|
||||
if (requestScoped && hasCustomUserVars && status?.configurationState !== 'configured') {
|
||||
return false;
|
||||
}
|
||||
if (status?.connectionState === 'connected') {
|
||||
return true;
|
||||
}
|
||||
if (!requestScoped) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
status?.authorizationState === 'not_required' || status?.authorizationState === 'authorized'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an action button should be shown for a server status.
|
||||
* Returns true only when the button would be actionable (not just informational).
|
||||
|
|
@ -183,8 +222,14 @@ export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProp
|
|||
if (isInitializing) return false;
|
||||
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
const { connectionState, requiresOAuth, requestScoped } = serverStatus;
|
||||
|
||||
// Request-scoped servers can only be initialized with an active MCP request context,
|
||||
// but their per-user variables must remain configurable while idle.
|
||||
if ((connectionState === 'disconnected' || connectionState === 'error') && requestScoped) {
|
||||
return hasCustomUserVars === true;
|
||||
}
|
||||
if (connectionState === 'connected' && requestScoped) return hasCustomUserVars === true;
|
||||
// Show for disconnected/error (can reconnect/configure)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') return true;
|
||||
// Show a cancel action for pending OAuth connections.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const mockSetValue = jest.fn();
|
|||
const mockGetValues = jest.fn((): string[] => []);
|
||||
const mockGetToolOptions = jest.fn((): Record<string, object> | undefined => undefined);
|
||||
const mockMcpServersMap = jest.fn((): Map<string, object> => new Map());
|
||||
const mockGetServerStatusIconProps = jest.fn((): object | null => null);
|
||||
const mockInitializeServer = jest.fn();
|
||||
const mockIsConnectionDeferred = jest.fn((): boolean => false);
|
||||
const mockToggleIntentAll = jest.fn();
|
||||
|
|
@ -20,6 +21,9 @@ const mockCapabilities = {
|
|||
backgroundToolsEnabled: false,
|
||||
toolIntentsEnabled: false,
|
||||
};
|
||||
const mockLocalize = jest.fn((key: string, values?: Record<number, string>) =>
|
||||
key === 'com_nav_mcp_status_connecting' ? `${values?.[0]} - Connecting` : key,
|
||||
);
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
|
||||
|
|
@ -46,12 +50,12 @@ jest.mock('~/components/ui', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useLocalize: () => mockLocalize,
|
||||
useCopyToClipboard: () => jest.fn(),
|
||||
useAgentCapabilities: () => mockCapabilities,
|
||||
useGetAgentsConfig: () => ({ agentsConfig: { capabilities: [] } }),
|
||||
useMCPServerManager: () => ({
|
||||
getServerStatusIconProps: () => null,
|
||||
getServerStatusIconProps: mockGetServerStatusIconProps,
|
||||
getConfigDialogProps: () => null,
|
||||
initializeServer: mockInitializeServer,
|
||||
isConnectionDeferred: mockIsConnectionDeferred,
|
||||
|
|
@ -121,6 +125,7 @@ jest.mock('@librechat/client', () => {
|
|||
const React = jest.requireActual('react');
|
||||
return {
|
||||
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
Spinner: ({ className }: { className?: string }) => React.createElement('span', { className }),
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
|
|
@ -181,6 +186,9 @@ describe('McpSection', () => {
|
|||
mockGetToolOptions.mockReturnValue(undefined);
|
||||
mockMcpServersMap.mockReset();
|
||||
mockMcpServersMap.mockReturnValue(new Map());
|
||||
mockGetServerStatusIconProps.mockReset();
|
||||
mockGetServerStatusIconProps.mockReturnValue(null);
|
||||
mockLocalize.mockClear();
|
||||
mockCodeInterpreterSelected.mockReset();
|
||||
mockCodeInterpreterSelected.mockReturnValue(false);
|
||||
mockCapabilities.codeEnabled = false;
|
||||
|
|
@ -196,6 +204,21 @@ describe('McpSection', () => {
|
|||
expect(screen.getByTestId('tool-mcp:srv:b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('interpolates the server name when another manager reports a connecting state', () => {
|
||||
mockGetServerStatusIconProps.mockReturnValue({
|
||||
serverStatus: {
|
||||
connectionState: 'connecting',
|
||||
requiresOAuth: true,
|
||||
},
|
||||
isInitializing: false,
|
||||
});
|
||||
|
||||
render(<McpSection item={item} />);
|
||||
|
||||
expect(screen.getByText('srv - Connecting')).toBeInTheDocument();
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_nav_mcp_status_connecting', { 0: 'srv' });
|
||||
});
|
||||
|
||||
test('toggling a tool writes its id plus the server token into agent.tools', () => {
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByTestId('tool-mcp:srv:a'));
|
||||
|
|
@ -282,13 +305,14 @@ describe('McpSection', () => {
|
|||
expect(screen.getByText('com_ui_tools_mcp_no_tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('lets an already-connected request-scoped server attach its runtime tools', () => {
|
||||
test('lets a ready request-scoped server attach its runtime tools', () => {
|
||||
const runtimeItem: McpItem = {
|
||||
...item,
|
||||
server: {
|
||||
...item.server,
|
||||
tools: [],
|
||||
isConnected: true,
|
||||
isConnected: false,
|
||||
isReadyForAgent: true,
|
||||
requestScoped: true,
|
||||
} as never,
|
||||
toolCount: 0,
|
||||
|
|
@ -320,6 +344,7 @@ describe('McpSection', () => {
|
|||
...item.server,
|
||||
tools: [],
|
||||
isConnected: true,
|
||||
isReadyForAgent: true,
|
||||
requestScoped: true,
|
||||
} as never,
|
||||
toolCount: 0,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
buildServerNameAliases,
|
||||
stripServerNamePrefix,
|
||||
} from 'librechat-data-provider';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { McpItem } from '../../items/types';
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
useMCPToolOptions,
|
||||
} from '~/hooks';
|
||||
import { matchesMcpServer, mcpAllToken, mcpServerToken } from '../../items/selectors';
|
||||
import { getStatusColor, getStatusTextKey } from '~/components/MCP/mcpServerUtils';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
|
||||
|
|
@ -38,29 +40,23 @@ interface StatusDisplay {
|
|||
}
|
||||
|
||||
function getStatusDisplay(
|
||||
connectionState: string | undefined,
|
||||
serverName: string,
|
||||
serverStatus: MCPServerStatus | undefined,
|
||||
isInitializing: boolean,
|
||||
isConfigured: boolean,
|
||||
): StatusDisplay {
|
||||
if (isInitializing || connectionState === 'connecting') {
|
||||
return {
|
||||
labelKey: 'com_nav_mcp_status_initializing',
|
||||
dotClass: 'bg-blue-500 animate-pulse',
|
||||
};
|
||||
if (!serverStatus && !isInitializing && !isConfigured) {
|
||||
return { labelKey: 'com_ui_tools_mcp_status_unconfigured', dotClass: 'bg-status-neutral' };
|
||||
}
|
||||
if (connectionState === 'connected') {
|
||||
return { labelKey: 'com_nav_mcp_status_connected', dotClass: 'bg-emerald-500' };
|
||||
}
|
||||
if (connectionState === 'error') {
|
||||
return { labelKey: 'com_nav_mcp_status_error', dotClass: 'bg-red-500' };
|
||||
}
|
||||
if (connectionState === 'disconnected') {
|
||||
return { labelKey: 'com_nav_mcp_status_disconnected', dotClass: 'bg-amber-500' };
|
||||
}
|
||||
if (!isConfigured) {
|
||||
return { labelKey: 'com_ui_tools_mcp_status_unconfigured', dotClass: 'bg-gray-400' };
|
||||
}
|
||||
return { labelKey: 'com_nav_mcp_status_unknown', dotClass: 'bg-gray-400' };
|
||||
const connectionStatus = serverStatus ? { [serverName]: serverStatus } : undefined;
|
||||
const initializing = () => isInitializing;
|
||||
return {
|
||||
labelKey: getStatusTextKey(serverName, connectionStatus, initializing) as TranslationKeys,
|
||||
dotClass: cn(
|
||||
getStatusColor(serverName, connectionStatus, initializing),
|
||||
(isInitializing || serverStatus?.connectionState === 'connecting') && 'animate-pulse',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -80,7 +76,7 @@ export default function McpSection({ item }: Props) {
|
|||
} = useMCPServerManager();
|
||||
const [oauthOpen, setOauthOpen] = useState(false);
|
||||
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
|
||||
const [prevConnected, setPrevConnected] = useState(false);
|
||||
const [prevReadyForAgent, setPrevReadyForAgent] = useState(false);
|
||||
const [autoSelectPending, setAutoSelectPending] = useState(false);
|
||||
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
|
|
@ -335,21 +331,27 @@ export default function McpSection({ item }: Props) {
|
|||
const configDialogProps = getConfigDialogProps();
|
||||
const connectionState = statusIconProps?.serverStatus?.connectionState;
|
||||
const isInitializing = statusIconProps?.isInitializing ?? false;
|
||||
const statusDisplay = getStatusDisplay(connectionState, isInitializing, liveServer.isConfigured);
|
||||
const statusDisplay = getStatusDisplay(
|
||||
serverName,
|
||||
statusIconProps?.serverStatus,
|
||||
isInitializing,
|
||||
liveServer.isConfigured,
|
||||
);
|
||||
/** A connected server's tools arrive with the (cold-cache) MCP tools fetch, and
|
||||
* the server is also briefly toolless while initializing — show a skeleton in
|
||||
* both cases instead of a misleading "no tools" message. */
|
||||
const toolsLoading =
|
||||
!hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting');
|
||||
const isConnected = connectionState === 'connected' || liveServer.isConnected === true;
|
||||
const isReadyForAgent = liveServer.isReadyForAgent ?? isConnected;
|
||||
const isBusy = isInitializing || connectionState === 'connecting';
|
||||
|
||||
/** Close + clear the OAuth dialog once the server connects, and don't let it
|
||||
/** Close + clear the OAuth dialog once the server is ready, and don't let it
|
||||
* reopen on its own if the connection later drops. No useEffect — adjust state
|
||||
* during render by comparing against the previous connection result. */
|
||||
if (prevConnected !== isConnected) {
|
||||
setPrevConnected(isConnected);
|
||||
if (isConnected) {
|
||||
if (prevReadyForAgent !== isReadyForAgent) {
|
||||
setPrevReadyForAgent(isReadyForAgent);
|
||||
if (isReadyForAgent) {
|
||||
setOauthOpen(false);
|
||||
setOauthUrl(null);
|
||||
}
|
||||
|
|
@ -370,7 +372,7 @@ export default function McpSection({ item }: Props) {
|
|||
const initConnectionDeferred = isConnectionDeferred(serverName);
|
||||
const requestScoped = liveServer.requestScoped === true;
|
||||
const runtimeToolsAvailable =
|
||||
!hasTools && !toolsLoading && (isWildcardAttached || (requestScoped && isConnected));
|
||||
!hasTools && !toolsLoading && (isWildcardAttached || (requestScoped && isReadyForAgent));
|
||||
const runtimeToolsMessage = isWildcardAttached
|
||||
? 'com_ui_tools_mcp_runtime_tools'
|
||||
: 'com_ui_tools_mcp_runtime_tools_available';
|
||||
|
|
@ -447,19 +449,19 @@ export default function McpSection({ item }: Props) {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{localize(statusDisplay.labelKey)}
|
||||
{localize(statusDisplay.labelKey, { 0: serverName })}
|
||||
</span>
|
||||
</div>
|
||||
{isConnected && statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
|
||||
{isReadyForAgent && statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
|
||||
</div>
|
||||
|
||||
{/* Connect collapses smoothly once connected. Its top spacing lives inside
|
||||
{/* Connect collapses smoothly once ready. Its top spacing lives inside
|
||||
* the reveal so the parent's flex gap never leaves a hole when it's gone,
|
||||
* and the auto-height dialog follows the grid-rows tween in one motion. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] [transition-duration:var(--resize-dur)] [transition-timing-function:var(--resize-ease)] motion-reduce:transition-none',
|
||||
isConnected ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]',
|
||||
isReadyForAgent ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
|
|
@ -468,8 +470,8 @@ export default function McpSection({ item }: Props) {
|
|||
variant="submit"
|
||||
className="mt-5 w-full gap-2"
|
||||
disabled={isBusy}
|
||||
tabIndex={isConnected ? -1 : undefined}
|
||||
aria-hidden={isConnected || undefined}
|
||||
tabIndex={isReadyForAgent ? -1 : undefined}
|
||||
aria-hidden={isReadyForAgent || undefined}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{isBusy && <Spinner className="size-4" />}
|
||||
|
|
@ -614,7 +616,7 @@ export default function McpSection({ item }: Props) {
|
|||
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
<McpOAuthDialog
|
||||
open={oauthOpen && !isConnected}
|
||||
open={oauthOpen && !isReadyForAgent}
|
||||
onOpenChange={setOauthOpen}
|
||||
serverName={serverName}
|
||||
oauthUrl={oauthUrl ?? getOAuthUrl(serverName) ?? ''}
|
||||
|
|
|
|||
|
|
@ -176,13 +176,13 @@ export default function ToolsMarketplaceDialog({
|
|||
}
|
||||
const wasSelected = selectedIds.has(itemKey(item));
|
||||
/** An unselected, toolless MCP server normally needs its setup dialog.
|
||||
* A connected request-scoped server is already ready and attaches via
|
||||
* An authorized request-scoped server is already ready and attaches via
|
||||
* its runtime wildcard; a selected toolless server must remain removable. */
|
||||
if (
|
||||
item.kind === 'mcp' &&
|
||||
item.toolCount === 0 &&
|
||||
!wasSelected &&
|
||||
!(item.server.requestScoped === true && item.server.isConnected === true)
|
||||
!(item.server.requestScoped === true && item.server.isReadyForAgent === true)
|
||||
) {
|
||||
setDetailItem(item);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('clicking a connected request-scoped zero-tool server attaches its runtime wildcard', () => {
|
||||
test('clicking a ready request-scoped zero-tool server attaches its runtime wildcard', () => {
|
||||
mockMcpServersMap = new Map([
|
||||
[
|
||||
'runtime',
|
||||
|
|
@ -237,7 +237,8 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
serverName: 'runtime',
|
||||
tools: [],
|
||||
isConfigured: true,
|
||||
isConnected: true,
|
||||
isConnected: false,
|
||||
isReadyForAgent: true,
|
||||
requestScoped: true,
|
||||
metadata: { name: 'runtime', pluginKey: 'runtime', description: '' },
|
||||
},
|
||||
|
|
@ -272,6 +273,7 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
tools: [],
|
||||
isConfigured: true,
|
||||
isConnected: true,
|
||||
isReadyForAgent: true,
|
||||
requestScoped: true,
|
||||
metadata: { name: 'runtime', pluginKey: 'runtime', description: '' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,4 +47,59 @@ describe('MCPCardActions', () => {
|
|||
expect(revokeButton).toHaveClass('hover:text-text-secondary');
|
||||
expect(revokeButton.querySelector('svg')).toHaveClass('text-text-destructive');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['disconnected', 'com_nav_mcp_connect'],
|
||||
['error', 'com_nav_mcp_connect'],
|
||||
['connected', 'com_nav_mcp_reconnect'],
|
||||
] as const)(
|
||||
'does not render a manual connection action when %s and on-demand',
|
||||
(state, label) => {
|
||||
render(
|
||||
<MCPCardActions
|
||||
serverName="server"
|
||||
serverStatus={{
|
||||
connectionState: state,
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
}}
|
||||
isInitializing={false}
|
||||
canCancel={false}
|
||||
hasCustomUserVars={false}
|
||||
canEdit={false}
|
||||
onEditClick={jest.fn()}
|
||||
onConfigClick={jest.fn()}
|
||||
onInitialize={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps custom-variable configuration available while an on-demand server is idle', () => {
|
||||
render(
|
||||
<MCPCardActions
|
||||
serverName="server"
|
||||
serverStatus={{
|
||||
connectionState: 'disconnected',
|
||||
requiresOAuth: false,
|
||||
requestScoped: true,
|
||||
configurationState: 'needs_configuration',
|
||||
}}
|
||||
isInitializing={false}
|
||||
canCancel={false}
|
||||
hasCustomUserVars={true}
|
||||
canEdit={false}
|
||||
onEditClick={jest.fn()}
|
||||
onConfigClick={jest.fn()}
|
||||
onInitialize={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'com_ui_configure' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_nav_mcp_connect' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export default function MCPCardActions({
|
|||
)}
|
||||
|
||||
{/* Connect button - for disconnected or error states */}
|
||||
{(isDisconnected || isError) && (
|
||||
{(isDisconnected || isError) && !serverStatus?.requestScoped && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_mcp_connect')}
|
||||
side="top"
|
||||
|
|
@ -138,8 +138,9 @@ export default function MCPCardActions({
|
|||
</TooltipAnchor>
|
||||
)}
|
||||
|
||||
{/* Configure button - for connected servers with custom vars */}
|
||||
{isConnected && hasCustomUserVars && (
|
||||
{/* On-demand servers stay idle between requests, so their user variables
|
||||
must remain configurable without a live transport connection. */}
|
||||
{(isConnected || serverStatus?.requestScoped) && hasCustomUserVars && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_configure')}
|
||||
side="top"
|
||||
|
|
@ -153,7 +154,7 @@ export default function MCPCardActions({
|
|||
)}
|
||||
|
||||
{/* Refresh button - for connected servers (allows reconnection) */}
|
||||
{isConnected && (
|
||||
{isConnected && !serverStatus?.requestScoped && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_mcp_reconnect')}
|
||||
side="top"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { MCPIcon } from '@librechat/client';
|
|||
import { PermissionBits, hasPermissions } from 'librechat-data-provider';
|
||||
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
|
||||
import type { MCPServerDefinition } from '~/hooks';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import { getStatusDotColor } from './MCPStatusBadge';
|
||||
import MCPCardActions from './MCPCardActions';
|
||||
import { useMCPServerManager, useLocalize } from '~/hooks';
|
||||
import { getStatusDotColor } from './MCPStatusBadge';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import MCPCardActions from './MCPCardActions';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPServerCardProps {
|
||||
|
|
@ -75,8 +75,9 @@ export default function MCPServerCard({
|
|||
if (isInitializing) return localize('com_nav_mcp_status_initializing');
|
||||
if (!serverStatus) return localize('com_nav_mcp_status_unknown');
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
if (connectionState === 'connected') return localize('com_nav_mcp_status_connected');
|
||||
if (connectionState === 'connecting') return localize('com_nav_mcp_status_connecting');
|
||||
if (serverStatus.requestScoped) return localize('com_nav_mcp_status_on_demand');
|
||||
if (connectionState === 'connected') return localize('com_nav_mcp_status_connected');
|
||||
if (connectionState === 'error') return localize('com_nav_mcp_status_error');
|
||||
if (connectionState === 'disconnected') {
|
||||
return requiresOAuth
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import MCPStatusBadge, { getStatusDotColor } from './MCPStatusBadge';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: (props: React.ComponentProps<'span'>) => <span {...props} />,
|
||||
}));
|
||||
|
||||
describe('MCPStatusBadge', () => {
|
||||
test.each(['disconnected', 'connected', 'error'] as const)(
|
||||
'renders the %s request-scoped state as on-demand',
|
||||
(connectionState) => {
|
||||
const serverStatus: MCPServerStatus = {
|
||||
connectionState,
|
||||
requiresOAuth: true,
|
||||
requestScoped: true,
|
||||
};
|
||||
|
||||
render(<MCPStatusBadge serverStatus={serverStatus} />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('com_nav_mcp_status_on_demand');
|
||||
expect(getStatusDotColor(serverStatus)).toBe('bg-status-info');
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves the active connecting state for a request-scoped OAuth flow', () => {
|
||||
const serverStatus: MCPServerStatus = {
|
||||
connectionState: 'connecting',
|
||||
requiresOAuth: true,
|
||||
requestScoped: true,
|
||||
};
|
||||
|
||||
render(<MCPStatusBadge serverStatus={serverStatus} />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('com_nav_mcp_status_connecting');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Spinner } from '@librechat/client';
|
||||
import { Check, PlugZap } from 'lucide-react';
|
||||
import { Check, PlugZap, Zap } from 'lucide-react';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -15,7 +15,7 @@ interface MCPStatusBadgeProps {
|
|||
*
|
||||
* Unified color system:
|
||||
* - Green: Connected/Active (success)
|
||||
* - Blue: Connecting/In-progress
|
||||
* - Blue: Connecting/In-progress or request-scoped on-demand
|
||||
* - Amber: Needs user action (OAuth required)
|
||||
* - Gray: Disconnected/Inactive (neutral)
|
||||
* - Red: Error
|
||||
|
|
@ -66,6 +66,15 @@ export default function MCPStatusBadge({
|
|||
);
|
||||
}
|
||||
|
||||
if (serverStatus.requestScoped) {
|
||||
return (
|
||||
<div role="status" className={cn(badgeBaseClass, 'bg-status-info-subtle text-status-info')}>
|
||||
<Zap className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_on_demand')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Disconnected state - check if needs action
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
|
|
@ -121,7 +130,7 @@ export default function MCPStatusBadge({
|
|||
*
|
||||
* Colors:
|
||||
* - Green: Connected
|
||||
* - Blue: Connecting/Initializing
|
||||
* - Blue: Connecting/Initializing or request-scoped on-demand
|
||||
* - Amber: Needs action (OAuth required while disconnected)
|
||||
* - Gray: Disconnected (neutral)
|
||||
* - Red: Error
|
||||
|
|
@ -144,6 +153,10 @@ export function getStatusDotColor(
|
|||
return 'bg-status-info';
|
||||
}
|
||||
|
||||
if (serverStatus.requestScoped) {
|
||||
return 'bg-status-info';
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
return 'bg-status-success';
|
||||
}
|
||||
|
|
@ -153,8 +166,10 @@ export function getStatusDotColor(
|
|||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
// Needs OAuth = amber, otherwise gray
|
||||
return requiresOAuth ? 'bg-status-warning' : 'bg-status-neutral';
|
||||
if (requiresOAuth) {
|
||||
return 'bg-status-warning';
|
||||
}
|
||||
return 'bg-status-neutral';
|
||||
}
|
||||
|
||||
return 'bg-status-neutral';
|
||||
|
|
|
|||
|
|
@ -169,9 +169,26 @@ export function useMCPServerManager({
|
|||
// Poll intervals are kept local (not serializable)
|
||||
const pollIntervalsRef = useRef<PollIntervals>({});
|
||||
|
||||
const { connectionStatus } = useMCPConnectionStatus({
|
||||
const { connectionStatus: polledConnectionStatus } = useMCPConnectionStatus({
|
||||
enabled: !isLoading && availableMCPServers.length > 0,
|
||||
});
|
||||
const connectionStatus = useMemo(() => {
|
||||
if (!polledConnectionStatus) {
|
||||
return polledConnectionStatus;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextStatus: MCPConnectionStatusResponse['connectionStatus'] = {};
|
||||
for (const [serverName, status] of Object.entries(polledConnectionStatus)) {
|
||||
if (status.requestScoped === true || loadedServers?.[serverName]?.requestScoped !== true) {
|
||||
nextStatus[serverName] = status;
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
nextStatus[serverName] = { ...status, requestScoped: true };
|
||||
}
|
||||
return changed ? nextStatus : polledConnectionStatus;
|
||||
}, [polledConnectionStatus, loadedServers]);
|
||||
|
||||
const updateServerInitState = useCallback(
|
||||
(serverName: string, updates: Partial<MCPServerInitState>) => {
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@
|
|||
"com_nav_mcp_status_error": "Error",
|
||||
"com_nav_mcp_status_initializing": "Initializing",
|
||||
"com_nav_mcp_status_needs_auth": "Needs Auth",
|
||||
"com_nav_mcp_status_on_demand": "On-demand",
|
||||
"com_nav_mcp_status_unknown": "Unknown",
|
||||
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables",
|
||||
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
|
||||
|
|
|
|||
|
|
@ -2540,6 +2540,32 @@ describe('initializeAgent — run-scoped MCP tool definitions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('threads the normalized MCP request body into tool discovery', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.tools = ['custom_tool'];
|
||||
const requestBody = {
|
||||
messageId: 'message-1',
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: 'parent-1',
|
||||
};
|
||||
|
||||
await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestBody,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(loadTools).toHaveBeenCalledWith(expect.objectContaining({ requestBody }));
|
||||
});
|
||||
|
||||
it('unions snapshot config names into the audit when the merged read omits them', async () => {
|
||||
/** The registry's merged read tolerates config-server init failures and
|
||||
* can silently drop config-only servers — the heal audit must restore
|
||||
|
|
|
|||
|
|
@ -273,6 +273,40 @@ describe('discoverConnectedAgents', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('forwards normalized request metadata to every handoff initializeAgent call', async () => {
|
||||
const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]);
|
||||
const getAgent = jest.fn(async () => makeAgent('B', []));
|
||||
const checkPermission = jest.fn().mockResolvedValue(true);
|
||||
const requestBody = {
|
||||
messageId: 'message-1',
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: 'parent-1',
|
||||
};
|
||||
|
||||
await discoverConnectedAgents(
|
||||
{
|
||||
req: makeReq(),
|
||||
res: makeRes(),
|
||||
primaryConfig,
|
||||
allowedProviders: new Set(),
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
loadTools: jest.fn(),
|
||||
requestBody,
|
||||
},
|
||||
{
|
||||
getAgent,
|
||||
checkPermission,
|
||||
logViolation: jest.fn(),
|
||||
db: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable=false verbatim so handoff agents respect disabled capability', async () => {
|
||||
/* Symmetric to the "true" case: when the primary resolved
|
||||
`codeEnvAvailable = false`, handoffs must NOT accidentally
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export interface DiscoverConnectedAgentsParams {
|
|||
requestFiles?: InitializeAgentParams['requestFiles'];
|
||||
conversationId?: string | null;
|
||||
parentMessageId?: string | null;
|
||||
/** Normalized runtime request metadata forwarded to MCP tool loading. */
|
||||
requestBody?: InitializeAgentParams['requestBody'];
|
||||
/**
|
||||
* ResourceType to check each sub-agent's access against. Defaults to
|
||||
* `AGENT` for the in-app chat flow. Callers whose entry-point gates on
|
||||
|
|
@ -230,6 +232,7 @@ async function initializeReferencedAgent(
|
|||
requestFiles: params.requestFiles,
|
||||
conversationId: params.conversationId,
|
||||
parentMessageId: params.parentMessageId,
|
||||
requestBody: params.requestBody,
|
||||
endpointOption: {
|
||||
...(params.endpointOption ?? {}),
|
||||
endpoint: EModelEndpoint.agents,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export interface ToolExecuteOptions {
|
|||
loadTools: (
|
||||
toolNames: string[],
|
||||
agentId?: string,
|
||||
/** Immutable run configuration available before deferred tools connect. */
|
||||
configurable?: Record<string, unknown>,
|
||||
) => Promise<{
|
||||
loadedTools: StructuredToolInterface[];
|
||||
/** Additional configurable properties to merge (e.g., userMCPAuthMap) */
|
||||
|
|
@ -3848,12 +3850,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
await runOutsideTracing(async () => {
|
||||
try {
|
||||
const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))];
|
||||
const sourceConfigurable = configurable as Record<string, unknown> | undefined;
|
||||
const { loadedTools, configurable: toolConfigurable } = await loadTools(
|
||||
toolNames,
|
||||
agentId,
|
||||
sourceConfigurable,
|
||||
);
|
||||
const toolMap = new Map(loadedTools.map((t) => [t.name, t]));
|
||||
const sourceConfigurable = configurable as Record<string, unknown> | undefined;
|
||||
const loadedConfigurable = toolConfigurable as Record<string, unknown> | undefined;
|
||||
const mergedConfigurable = mergeToolConfigurables(
|
||||
sourceConfigurable,
|
||||
|
|
|
|||
|
|
@ -26,18 +26,19 @@ import type {
|
|||
import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents';
|
||||
import type { IMongoFile, FileOwnerScope } from '@librechat/data-schemas';
|
||||
import type { Response as ServerResponse } from 'express';
|
||||
import type {
|
||||
ServerRequest,
|
||||
RequestBody,
|
||||
EndpointDbMethods,
|
||||
EndpointTokenConfig,
|
||||
InitializeResultBase,
|
||||
} from '~/types';
|
||||
import type {
|
||||
ResolvedManualSkill,
|
||||
ResolvedAlwaysApplySkill,
|
||||
TListSkillsByAccess,
|
||||
TGetSkillByName,
|
||||
} from './skills';
|
||||
import type {
|
||||
ServerRequest,
|
||||
EndpointDbMethods,
|
||||
EndpointTokenConfig,
|
||||
InitializeResultBase,
|
||||
} from '~/types';
|
||||
import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types';
|
||||
import type { TFilterFilesByAgentAccess } from './resources';
|
||||
import type { MCPToolAlias } from '~/tools/classification';
|
||||
|
|
@ -417,6 +418,8 @@ export interface InitializeAgentParams {
|
|||
conversationId?: string | null;
|
||||
/** Parent message ID for determining the current thread (optional) */
|
||||
parentMessageId?: string | null;
|
||||
/** Normalized body used by MCP runtime placeholders during tool discovery. */
|
||||
requestBody?: RequestBody;
|
||||
/** Request files */
|
||||
requestFiles?: IMongoFile[];
|
||||
/** Function to load agent tools */
|
||||
|
|
@ -429,6 +432,7 @@ export interface InitializeAgentParams {
|
|||
model: string | null;
|
||||
tool_options: AgentToolOptions | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
requestBody?: RequestBody;
|
||||
/** Trusted endpoint/profile resolved for this agent before any code-file priming. */
|
||||
codeExecutionContext: CodeExecutionContext;
|
||||
/** Full accessible MCP server names (operator + user DB) when the heal
|
||||
|
|
@ -613,6 +617,7 @@ export async function initializeAgent(
|
|||
conversationId,
|
||||
endpointOption,
|
||||
parentMessageId,
|
||||
requestBody,
|
||||
allowedProviders,
|
||||
isInitialAgent = false,
|
||||
} = params;
|
||||
|
|
@ -1068,6 +1073,7 @@ export async function initializeAgent(
|
|||
model: agent.model,
|
||||
tool_options: agent.tool_options,
|
||||
tool_resources,
|
||||
requestBody,
|
||||
codeExecutionContext,
|
||||
accessibleMcpServerNames: resolvedAuditNames,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { GraphEvents } from '@librechat/agents';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import type { ChatCompletionDependencies } from './service';
|
||||
import { createAgentChatCompletion } from './service';
|
||||
|
|
@ -15,6 +16,7 @@ type CreateRunArgs = {
|
|||
user?: Record<string, unknown>;
|
||||
tenantId?: string;
|
||||
appConfig?: Record<string, unknown>;
|
||||
requestBody?: Record<string, unknown>;
|
||||
};
|
||||
type ProcessStreamConfig = { configurable?: Record<string, unknown> };
|
||||
|
||||
|
|
@ -110,6 +112,88 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => {
|
|||
expect(streamConfig.configurable?.user).not.toHaveProperty('role');
|
||||
});
|
||||
|
||||
it('threads the parent message id into the run and execution context', async () => {
|
||||
const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as {
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
req.body.parent_message_id = 'parent-123';
|
||||
|
||||
await createAgentChatCompletion(req as never, createMockRes(), deps);
|
||||
|
||||
expect(deps.initializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({ parentMessageId: 'parent-123' }),
|
||||
}),
|
||||
);
|
||||
const runArgs = createRun.mock.calls[0][0] as CreateRunArgs;
|
||||
expect(runArgs.requestBody).toEqual(expect.objectContaining({ parentMessageId: 'parent-123' }));
|
||||
const streamConfig = processStream.mock.calls[0][1] as ProcessStreamConfig;
|
||||
expect(streamConfig.configurable?.requestBody).toEqual(runArgs.requestBody);
|
||||
});
|
||||
|
||||
it('forwards the normalized MCP body to deferred execution loaders', async () => {
|
||||
const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as {
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
req.body.stream = true;
|
||||
req.body.parent_message_id = 'parent-123';
|
||||
const loadTools = jest.fn().mockResolvedValue({ loadedTools: [] });
|
||||
deps.toolExecuteOptions = { loadTools };
|
||||
|
||||
await createAgentChatCompletion(req as never, createMockRes(), deps);
|
||||
|
||||
const runArgs = createRun.mock.calls[0][0] as CreateRunArgs & {
|
||||
customHandlers: Record<string, { handle: (event: string, data: unknown) => Promise<void> }>;
|
||||
};
|
||||
const streamConfig = processStream.mock.calls[0][1] as ProcessStreamConfig;
|
||||
const resolve = jest.fn();
|
||||
const reject = jest.fn();
|
||||
await runArgs.customHandlers[GraphEvents.ON_TOOL_EXECUTE].handle(GraphEvents.ON_TOOL_EXECUTE, {
|
||||
toolCalls: [{ id: 'tool-call-1', name: 'deferred_mcp_tool', args: {} }],
|
||||
agentId: 'agent_test',
|
||||
configurable: streamConfig.configurable,
|
||||
metadata: {},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
expect(loadTools).toHaveBeenCalledWith(
|
||||
['deferred_mcp_tool'],
|
||||
'agent_test',
|
||||
expect.objectContaining({ requestBody: runArgs.requestBody }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the root parent sentinel when chat completions omit a parent id', async () => {
|
||||
const req = createMockReq({ id: 'user-123', role: 'USER' });
|
||||
|
||||
await createAgentChatCompletion(req, createMockRes(), deps);
|
||||
|
||||
expect(deps.initializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({
|
||||
parentMessageId: '00000000-0000-0000-0000-000000000000',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits an unavailable parent for an existing chat-completions conversation', async () => {
|
||||
const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as {
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
req.body.conversation_id = 'conversation-123';
|
||||
|
||||
await createAgentChatCompletion(req as never, createMockRes(), deps);
|
||||
|
||||
const requestBody = (deps.initializeAgent as jest.Mock).mock.calls[0][0].requestBody;
|
||||
expect(requestBody).toEqual({
|
||||
messageId: expect.any(String),
|
||||
conversationId: 'conversation-123',
|
||||
});
|
||||
expect(requestBody).not.toHaveProperty('parentMessageId');
|
||||
});
|
||||
|
||||
it('forwards appConfig and tenantId to createRun', async () => {
|
||||
const appConfig = {
|
||||
endpoints: {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
ToolCall,
|
||||
} from './types';
|
||||
import type { OpenAIStreamHandlerConfig, EventHandler } from './handlers';
|
||||
import type { MCPRuntimeRequestBody } from '~/mcp/request';
|
||||
import type { ToolExecuteOptions } from '../handlers';
|
||||
import {
|
||||
createOpenAIContentAggregator,
|
||||
|
|
@ -41,6 +42,7 @@ import {
|
|||
createChunk,
|
||||
writeSSE,
|
||||
} from './handlers';
|
||||
import { createMCPRuntimeRequestBody } from '~/mcp/request';
|
||||
import { createSafeUser } from '~/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -135,6 +137,7 @@ interface InitializeAgentParams {
|
|||
agent: Agent;
|
||||
conversationId?: string | null;
|
||||
parentMessageId?: string | null;
|
||||
requestBody?: MCPRuntimeRequestBody;
|
||||
requestFiles?: unknown[];
|
||||
loadTools?: LoadToolsFn;
|
||||
endpointOption?: Record<string, unknown>;
|
||||
|
|
@ -191,6 +194,7 @@ type LoadToolsFn = (params: {
|
|||
model: string | null;
|
||||
tool_options: unknown;
|
||||
tool_resources: unknown;
|
||||
requestBody?: MCPRuntimeRequestBody;
|
||||
}) => Promise<{
|
||||
tools: unknown[];
|
||||
toolContextMap: Record<string, unknown>;
|
||||
|
|
@ -435,6 +439,17 @@ export async function createAgentChatCompletion(
|
|||
// Generate IDs
|
||||
const requestId = `chatcmpl-${nanoid()}`;
|
||||
const conversationId = request.conversation_id ?? nanoid();
|
||||
let mcpParentMessageId: string | null | undefined;
|
||||
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: requestId,
|
||||
conversationId,
|
||||
parentMessageId: mcpParentMessageId,
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Build response context
|
||||
|
|
@ -502,6 +517,7 @@ export async function createAgentChatCompletion(
|
|||
agent,
|
||||
conversationId,
|
||||
parentMessageId: request.parent_message_id,
|
||||
requestBody: mcpRequestBody,
|
||||
loadTools: deps.loadAgentTools,
|
||||
endpointOption: {
|
||||
endpoint: agent.provider,
|
||||
|
|
@ -570,17 +586,13 @@ export async function createAgentChatCompletion(
|
|||
* correctly leaves MCP gated.
|
||||
*/
|
||||
const safeUser: Record<string, unknown> = { ...createSafeUser(reqUser), id: userId };
|
||||
|
||||
const run = await deps.createRun({
|
||||
agents: [initializedAgent],
|
||||
messages,
|
||||
runId: requestId,
|
||||
signal: abortController.signal,
|
||||
customHandlers: eventHandlers,
|
||||
requestBody: {
|
||||
messageId: requestId,
|
||||
conversationId,
|
||||
},
|
||||
requestBody: mcpRequestBody,
|
||||
user: safeUser,
|
||||
tenantId: typeof reqUser?.tenantId === 'string' ? reqUser.tenantId : undefined,
|
||||
appConfig: deps.appConfig
|
||||
|
|
@ -600,6 +612,7 @@ export async function createAgentChatCompletion(
|
|||
thread_id: conversationId,
|
||||
user_id: userId,
|
||||
user: safeUser,
|
||||
requestBody: mcpRequestBody,
|
||||
/** Same per-agent channel the in-repo controllers thread via
|
||||
* `loadTools`: without it, the executor's PTC path cannot
|
||||
* strip host-injected `intent` params from the schemas the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { EventEmitter } from 'events';
|
||||
|
||||
import { getMCPRequestContext, cleanupMCPRequestContextForReq } from '~/mcp/request';
|
||||
import {
|
||||
createMCPRuntimeRequestBody,
|
||||
getMCPRequestContext,
|
||||
cleanupMCPRequestContextForReq,
|
||||
} from '~/mcp/request';
|
||||
import { getMissingRuntimeBodyPlaceholderFields } from '~/mcp/utils';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
|
|
@ -105,3 +110,47 @@ describe('MCP request context', () => {
|
|||
expect(connection.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP runtime request body', () => {
|
||||
it('preserves a supplied parent message id', () => {
|
||||
expect(
|
||||
createMCPRuntimeRequestBody({
|
||||
messageId: 'response-1',
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: 'parent-1',
|
||||
}),
|
||||
).toEqual({
|
||||
messageId: 'response-1',
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: 'parent-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the root-turn parent sentinel for an explicit root parent', () => {
|
||||
expect(
|
||||
createMCPRuntimeRequestBody({
|
||||
messageId: 'response-1',
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: null,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ parentMessageId: '00000000-0000-0000-0000-000000000000' }));
|
||||
});
|
||||
|
||||
it('leaves the parent absent when the protocol cannot supply that identity', () => {
|
||||
const requestBody = createMCPRuntimeRequestBody({
|
||||
messageId: 'response-1',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(requestBody).toEqual({ messageId: 'response-1', conversationId: 'conversation-1' });
|
||||
expect(
|
||||
getMissingRuntimeBodyPlaceholderFields(
|
||||
{
|
||||
source: 'yaml',
|
||||
headers: { 'X-Parent': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' },
|
||||
},
|
||||
requestBody,
|
||||
),
|
||||
).toEqual(['parentMessageId']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface RequestScopedTestServer {
|
|||
sessionsCreated: () => number;
|
||||
toolCallCount: () => number;
|
||||
observedRunIds: () => string[];
|
||||
observedParentMessageIds: () => string[];
|
||||
}
|
||||
|
||||
function trackSockets(httpServer: http.Server): () => Promise<void> {
|
||||
|
|
@ -72,6 +73,7 @@ function trackSockets(httpServer: http.Server): () => Promise<void> {
|
|||
async function createRequestScopedTestServer(): Promise<RequestScopedTestServer> {
|
||||
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const runIds: string[] = [];
|
||||
const parentMessageIds: string[] = [];
|
||||
let created = 0;
|
||||
let deletes = 0;
|
||||
let toolCalls = 0;
|
||||
|
|
@ -87,6 +89,10 @@ async function createRequestScopedTestServer(): Promise<RequestScopedTestServer>
|
|||
if (typeof runId === 'string') {
|
||||
runIds.push(runId);
|
||||
}
|
||||
const parentMessageId = req.headers['x-parent-message'];
|
||||
if (typeof parentMessageId === 'string') {
|
||||
parentMessageIds.push(parentMessageId);
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
deletes += 1;
|
||||
}
|
||||
|
|
@ -127,6 +133,7 @@ async function createRequestScopedTestServer(): Promise<RequestScopedTestServer>
|
|||
sessionsCreated: () => created,
|
||||
toolCallCount: () => toolCalls,
|
||||
observedRunIds: () => [...runIds],
|
||||
observedParentMessageIds: () => [...parentMessageIds],
|
||||
close: async () => {
|
||||
const closing = [...sessions.values()].map((transport) =>
|
||||
transport.close().catch(() => undefined),
|
||||
|
|
@ -157,7 +164,10 @@ function createServerConfig(url: string): ParsedServerConfig {
|
|||
source: 'yaml',
|
||||
requiresOAuth: false,
|
||||
initTimeout: 500,
|
||||
headers: { 'X-Run-Id': '{{LIBRECHAT_BODY_MESSAGEID}}' },
|
||||
headers: {
|
||||
'X-Run-Id': '{{LIBRECHAT_BODY_MESSAGEID}}',
|
||||
'X-Parent-Message': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +256,7 @@ describe('request-scoped MCP lifecycle integration', () => {
|
|||
expect(server.liveSessionCount()).toBe(1);
|
||||
expect(server.toolCallCount()).toBe(burstSize);
|
||||
expect(new Set(server.observedRunIds())).toEqual(new Set(['run-1']));
|
||||
expect(new Set(server.observedParentMessageIds())).toEqual(new Set(['parent-1']));
|
||||
expect(manager.getConnectionStats().activityEntries).toBe(0);
|
||||
|
||||
await cleanupMCPRequestContext(firstRun);
|
||||
|
|
@ -266,6 +277,7 @@ describe('request-scoped MCP lifecycle integration', () => {
|
|||
expect(server.sessionsCreated()).toBe(2);
|
||||
expect(server.liveSessionCount()).toBe(1);
|
||||
expect(new Set(server.observedRunIds())).toEqual(new Set(['run-1', 'run-2']));
|
||||
expect(new Set(server.observedParentMessageIds())).toEqual(new Set(['parent-1']));
|
||||
});
|
||||
|
||||
it('clears a failed run so the same server can recover in a fresh run', async () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
getMissingCustomUserVars,
|
||||
hasCustomUserVars,
|
||||
hasRuntimeUrlPlaceholders,
|
||||
hasRuntimeBodyPlaceholders,
|
||||
getMCPRequestScope,
|
||||
hasRuntimeContextPlaceholders,
|
||||
getRuntimeBodyPlaceholderFields,
|
||||
getMissingRuntimeBodyPlaceholderFields,
|
||||
|
|
@ -814,32 +814,32 @@ describe('hasRuntimeUrlPlaceholders', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('hasRuntimeBodyPlaceholders', () => {
|
||||
describe('getMCPRequestScope', () => {
|
||||
it('detects trusted runtime BODY placeholders across connection fields', () => {
|
||||
expect(
|
||||
hasRuntimeBodyPlaceholders({
|
||||
getMCPRequestScope({
|
||||
source: 'yaml',
|
||||
url: 'https://example.com/conversations/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp',
|
||||
}),
|
||||
}).requestScoped,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
hasRuntimeBodyPlaceholders({
|
||||
getMCPRequestScope({
|
||||
source: 'config',
|
||||
headers: {
|
||||
'X-Message': '{{LIBRECHAT_BODY_MESSAGEID}}',
|
||||
},
|
||||
}),
|
||||
}).requestScoped,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores BODY placeholders in user-sourced configs', () => {
|
||||
expect(
|
||||
hasRuntimeBodyPlaceholders({
|
||||
getMCPRequestScope({
|
||||
source: 'user',
|
||||
dbId: 'server-123',
|
||||
url: 'https://example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
||||
}),
|
||||
}).requestScoped,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -851,7 +851,7 @@ describe('hasRuntimeBodyPlaceholders', () => {
|
|||
|
||||
expect(hasRuntimeContextPlaceholders(config)).toBe(false);
|
||||
expect(hasRuntimeUrlPlaceholders(config)).toBe(false);
|
||||
expect(hasRuntimeBodyPlaceholders(config)).toBe(false);
|
||||
expect(getMCPRequestScope(config).requestScoped).toBe(false);
|
||||
expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]);
|
||||
expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]);
|
||||
expect(requiresEphemeralUserConnection(config)).toBe(false);
|
||||
|
|
@ -869,7 +869,7 @@ describe('hasRuntimeBodyPlaceholders', () => {
|
|||
|
||||
expect(hasRuntimeContextPlaceholders(config)).toBe(false);
|
||||
expect(hasRuntimeUrlPlaceholders(config)).toBe(false);
|
||||
expect(hasRuntimeBodyPlaceholders(config)).toBe(false);
|
||||
expect(getMCPRequestScope(config).requestScoped).toBe(false);
|
||||
expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]);
|
||||
expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]);
|
||||
expect(requiresEphemeralUserConnection(config)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,33 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
|
||||
import type { RequestScopedMCPConnectionStore } from './types';
|
||||
import type { MCPRuntimeRequestBody, RequestScopedMCPConnectionStore } from './types';
|
||||
|
||||
export type { MCPRuntimeRequestBody } from './types';
|
||||
|
||||
/**
|
||||
* Builds the complete request context that runtime MCP placeholders may resolve.
|
||||
* An explicit null parent means a known root turn and becomes the root sentinel.
|
||||
* An omitted parent stays omitted so protocols without parent-message identity
|
||||
* fail closed for configurations that require that BODY placeholder.
|
||||
*/
|
||||
export function createMCPRuntimeRequestBody({
|
||||
messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
}: {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
parentMessageId?: string | null;
|
||||
}): MCPRuntimeRequestBody {
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
...(parentMessageId !== undefined && {
|
||||
parentMessageId: parentMessageId ?? Constants.NO_PARENT,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MCPRequestContext extends RequestScopedMCPConnectionStore {
|
||||
cleanupStarted: boolean;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import type { FlowStateManager } from '~/flow/manager';
|
|||
import type { RequestBody } from '~/types/http';
|
||||
import type * as o from '~/mcp/oauth/types';
|
||||
|
||||
export type MCPRuntimeRequestBody = Required<Pick<RequestBody, 'messageId' | 'conversationId'>> &
|
||||
Pick<RequestBody, 'parentMessageId'>;
|
||||
|
||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,11 @@ type PlaceholderValue =
|
|||
| readonly PlaceholderValue[]
|
||||
| { readonly [key: string]: PlaceholderValue };
|
||||
|
||||
export interface MCPRequestScope {
|
||||
requestScoped: boolean;
|
||||
requiredBodyFields: Array<keyof RequestBody>;
|
||||
}
|
||||
|
||||
type UserScopedConnectionConfig = Pick<
|
||||
ParsedServerConfig,
|
||||
'requiresOAuth' | 'source' | 'dbId' | 'startup'
|
||||
|
|
@ -281,7 +286,10 @@ function hasPlaceholder(value: PlaceholderValue, pattern: RegExp): boolean {
|
|||
return Object.values(value).some((item) => hasPlaceholder(item, pattern));
|
||||
}
|
||||
|
||||
function addRuntimeBodyPlaceholderFields(value: PlaceholderValue, fields: Set<string>): void {
|
||||
function addRuntimeBodyPlaceholderFields(
|
||||
value: PlaceholderValue,
|
||||
fields: Set<keyof RequestBody>,
|
||||
): void {
|
||||
if (typeof value === 'string') {
|
||||
for (const match of value.matchAll(RUNTIME_BODY_PLACEHOLDER_CAPTURE_PATTERN)) {
|
||||
const placeholderKey = match[1];
|
||||
|
|
@ -335,34 +343,32 @@ export function hasRuntimeUrlPlaceholders(config: UserScopedConnectionConfig): b
|
|||
return hasRuntimeContextPlaceholder(config.url);
|
||||
}
|
||||
|
||||
export function hasRuntimeBodyPlaceholders(config: UserScopedConnectionConfig): boolean {
|
||||
export function getMCPRequestScope(config: UserScopedConnectionConfig): MCPRequestScope {
|
||||
if (!canResolveRuntimePlaceholders(config)) {
|
||||
return false;
|
||||
return { requestScoped: false, requiredBodyFields: [] };
|
||||
}
|
||||
|
||||
return placeholderBearingFields(config).some((value) =>
|
||||
hasPlaceholder(value, RUNTIME_BODY_PLACEHOLDER_PATTERN),
|
||||
);
|
||||
const requiredBodyFields = new Set<keyof RequestBody>();
|
||||
for (const value of placeholderBearingFields(config)) {
|
||||
addRuntimeBodyPlaceholderFields(value, requiredBodyFields);
|
||||
}
|
||||
|
||||
const fields = Array.from(requiredBodyFields);
|
||||
return { requestScoped: fields.length > 0, requiredBodyFields: fields };
|
||||
}
|
||||
|
||||
export function getRuntimeBodyPlaceholderFields(config: UserScopedConnectionConfig): string[] {
|
||||
if (!canResolveRuntimePlaceholders(config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = new Set<string>();
|
||||
for (const value of placeholderBearingFields(config)) {
|
||||
addRuntimeBodyPlaceholderFields(value, fields);
|
||||
}
|
||||
return Array.from(fields);
|
||||
export function getRuntimeBodyPlaceholderFields(
|
||||
config: UserScopedConnectionConfig,
|
||||
): Array<keyof RequestBody> {
|
||||
return getMCPRequestScope(config).requiredBodyFields;
|
||||
}
|
||||
|
||||
export function getMissingRuntimeBodyPlaceholderFields(
|
||||
config: UserScopedConnectionConfig,
|
||||
requestBody?: RequestBody,
|
||||
): string[] {
|
||||
return getRuntimeBodyPlaceholderFields(config).filter((field) => {
|
||||
const value = requestBody?.[field as keyof RequestBody];
|
||||
return getMCPRequestScope(config).requiredBodyFields.filter((field) => {
|
||||
const value = requestBody?.[field];
|
||||
return value == null || (typeof value === 'string' && value.trim() === '');
|
||||
});
|
||||
}
|
||||
|
|
@ -380,13 +386,7 @@ export function getMissingRuntimeBodyPlaceholderFields(
|
|||
* connection without forcing a reconnect for every invocation.
|
||||
*/
|
||||
export function requiresEphemeralUserConnection(config: UserScopedConnectionConfig): boolean {
|
||||
if (!canResolveRuntimePlaceholders(config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return placeholderBearingFields(config).some((value) =>
|
||||
hasPlaceholder(value, RUNTIME_BODY_PLACEHOLDER_PATTERN),
|
||||
);
|
||||
return getMCPRequestScope(config).requestScoped;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2529,6 +2529,7 @@ class GenerationJobManagerClass {
|
|||
userMessage: jobData.userMessage,
|
||||
responseMessageId: jobData.responseMessageId,
|
||||
isRegenerate: jobData.isRegenerate,
|
||||
mcpRequestBody: jobData.mcpRequestBody,
|
||||
sender: jobData.sender,
|
||||
endpoint: jobData.endpoint,
|
||||
iconURL: jobData.iconURL,
|
||||
|
|
|
|||
|
|
@ -327,6 +327,11 @@ describe('RedisJobStore', () => {
|
|||
parentMessageId: 'parent-1',
|
||||
},
|
||||
responseMessageId: 'response-1',
|
||||
mcpRequestBody: {
|
||||
messageId: 'response-1',
|
||||
conversationId: 'overridden-conversation',
|
||||
parentMessageId: 'response-1',
|
||||
},
|
||||
sender: 'Agent',
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/icon.png',
|
||||
|
|
@ -389,6 +394,11 @@ describe('RedisJobStore', () => {
|
|||
parentMessageId: 'parent-1',
|
||||
},
|
||||
responseMessageId: 'response-1',
|
||||
mcpRequestBody: {
|
||||
messageId: 'response-1',
|
||||
conversationId: 'overridden-conversation',
|
||||
parentMessageId: 'response-1',
|
||||
},
|
||||
sender: 'Agent',
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/icon.png',
|
||||
|
|
@ -423,6 +433,11 @@ describe('RedisJobStore', () => {
|
|||
expect(storedFields).toMatchObject({
|
||||
conversationId: 'conversation-1',
|
||||
responseMessageId: 'response-1',
|
||||
mcpRequestBody: JSON.stringify({
|
||||
messageId: 'response-1',
|
||||
conversationId: 'overridden-conversation',
|
||||
parentMessageId: 'response-1',
|
||||
}),
|
||||
agent_id: 'agent-1',
|
||||
isTemporary: '0',
|
||||
scheduleId: 'schedule-1',
|
||||
|
|
|
|||
|
|
@ -100,6 +100,11 @@ describe('GenerationJobManager startup telemetry', () => {
|
|||
},
|
||||
responseMessageId: 'response-1',
|
||||
isRegenerate: true,
|
||||
mcpRequestBody: {
|
||||
messageId: 'response-1',
|
||||
conversationId: 'overridden-conversation',
|
||||
parentMessageId: 'response-1',
|
||||
},
|
||||
sender: 'Agent',
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/icon.png',
|
||||
|
|
@ -131,6 +136,11 @@ describe('GenerationJobManager startup telemetry', () => {
|
|||
},
|
||||
responseMessageId: 'response-1',
|
||||
isRegenerate: true,
|
||||
mcpRequestBody: {
|
||||
messageId: 'response-1',
|
||||
conversationId: 'overridden-conversation',
|
||||
parentMessageId: 'response-1',
|
||||
},
|
||||
sender: 'Agent',
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/icon.png',
|
||||
|
|
|
|||
|
|
@ -4548,6 +4548,7 @@ export class RedisJobStore implements IJobStoreV2 {
|
|||
userMessage: data.userMessage ? JSON.parse(data.userMessage) : undefined,
|
||||
responseMessageId: data.responseMessageId || undefined,
|
||||
isRegenerate: data.isRegenerate != null ? data.isRegenerate === '1' : undefined,
|
||||
mcpRequestBody: data.mcpRequestBody ? JSON.parse(data.mcpRequestBody) : undefined,
|
||||
createdEventEmitted: data.createdEventEmitted === '1',
|
||||
sender: data.sender || undefined,
|
||||
syncSent: data.syncSent === '1',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { StandardGraph } from '@librechat/agents';
|
|||
import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime';
|
||||
import type { ResolvedAskUserQuestion } from '~/agents/hitl/resume';
|
||||
import type { RecoveredSteerPayload } from '../SteerRecovery';
|
||||
import type { MCPRuntimeRequestBody } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* A pause owner has this long to durably persist the interrupted turn before
|
||||
|
|
@ -77,6 +78,8 @@ export interface SerializableJobData {
|
|||
|
||||
/** Whether this generation replaces an existing assistant branch. */
|
||||
isRegenerate?: boolean;
|
||||
/** Exact normalized MCP placeholder identity for this turn. */
|
||||
mcpRequestBody?: MCPRuntimeRequestBody;
|
||||
|
||||
/**
|
||||
* Whether this run has activity labels enabled (per-endpoint
|
||||
|
|
@ -298,6 +301,7 @@ export type JobMetadataPatch = Partial<
|
|||
SerializableJobData,
|
||||
| 'responseMessageId'
|
||||
| 'isRegenerate'
|
||||
| 'mcpRequestBody'
|
||||
| 'sender'
|
||||
| 'conversationId'
|
||||
| 'userMessage'
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export function sanitizeJobMetadata(metadata: Partial<GenerationJobMetadata>): J
|
|||
if (metadata.isRegenerate !== undefined) {
|
||||
patch.isRegenerate = metadata.isRegenerate;
|
||||
}
|
||||
if (metadata.mcpRequestBody) {
|
||||
patch.mcpRequestBody = metadata.mcpRequestBody;
|
||||
}
|
||||
if (metadata.sender) {
|
||||
patch.sender = metadata.sender;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Agents } from 'librechat-data-provider';
|
|||
import type { EventEmitter } from 'events';
|
||||
import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime';
|
||||
import type { ResolvedAskUserQuestion } from '../agents/hitl/resume';
|
||||
import type { MCPRuntimeRequestBody } from '../mcp/types';
|
||||
import type { ServerSentEvent } from './events';
|
||||
|
||||
export interface GenerationJobMetadata {
|
||||
|
|
@ -19,6 +20,9 @@ export interface GenerationJobMetadata {
|
|||
responseMessageId?: string;
|
||||
/** Whether this generation replaces an existing assistant branch. */
|
||||
isRegenerate?: boolean;
|
||||
/** Exact normalized MCP placeholder identity for this turn. Persisted so HITL
|
||||
* resume does not reconstruct a different parent or overridden conversation. */
|
||||
mcpRequestBody?: MCPRuntimeRequestBody;
|
||||
/** Sender label for the response (e.g., "GPT-4.1", "Claude") */
|
||||
sender?: string;
|
||||
/** Endpoint identifier for abort handling */
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ export type ListRolesResponse = {
|
|||
|
||||
export interface MCPServerStatus {
|
||||
requiresOAuth: boolean;
|
||||
/** The server connects only inside a chat request because its config reads BODY placeholders. */
|
||||
requestScoped?: boolean;
|
||||
/** Whether all declared per-user variables are present for an on-demand connection. */
|
||||
configurationState?: 'configured' | 'needs_configuration';
|
||||
connectionState: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
authorizationState?:
|
||||
| 'not_required'
|
||||
|
|
@ -232,6 +236,8 @@ export interface MCPServerConnectionStatusResponse {
|
|||
success: boolean;
|
||||
serverName: string;
|
||||
requiresOAuth: boolean;
|
||||
requestScoped?: boolean;
|
||||
configurationState?: MCPServerStatus['configurationState'];
|
||||
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
authorizationState?: MCPServerStatus['authorizationState'];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue