From 8ae94afa919cfa3fe739ea2f5b4f97d6e963b65f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 21 Aug 2026 16:33:01 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A1=20fix:=20Thread=20Parent=20Message?= =?UTF-8?q?=20ID=20Through=20MCP=20Request-Scoped=20Bodies=20(#15095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CONTEXT.md | 1 + api/app/clients/BaseClient.js | 12 +- api/app/clients/specs/BaseClient.test.js | 15 +++ api/app/clients/tools/util/handleTools.js | 2 +- .../agents/__tests__/openai.spec.js | 85 +++++++++++- .../request.partialDisconnect.spec.js | 5 + .../__tests__/request.resumeMetadata.spec.js | 126 +++++++++++++++++- .../agents/__tests__/responses.unit.spec.js | 21 ++- .../agents/__tests__/resume.spec.js | 30 ++++- api/server/controllers/agents/client.js | 25 ++-- api/server/controllers/agents/openai.js | 27 ++-- api/server/controllers/agents/request.js | 72 +++++++--- api/server/controllers/agents/responses.js | 28 ++-- api/server/controllers/agents/resume.js | 8 ++ api/server/routes/__tests__/mcp.spec.js | 27 ++++ api/server/routes/mcp.js | 6 + .../services/Endpoints/agents/addedConvo.js | 3 + .../services/Endpoints/agents/initialize.js | 11 ++ .../Endpoints/agents/initialize.spec.js | 28 +++- api/server/services/MCP.js | 22 ++- api/server/services/MCP.spec.js | 55 ++++++++ api/server/services/ToolService.js | 30 ++++- .../services/__tests__/ToolService.spec.js | 32 +++++ client/src/Providers/AgentPanelContext.tsx | 17 ++- client/src/common/types.ts | 2 + .../MCP/MCPServerStatusIcon.spec.tsx | 71 ++++++++++ .../components/MCP/MCPServerStatusIcon.tsx | 17 ++- .../MCP/ServerInitializationSection.spec.tsx | 78 +++++++++++ .../MCP/ServerInitializationSection.tsx | 46 ++++--- .../src/components/MCP/mcpServerUtils.spec.ts | 66 +++++++++ client/src/components/MCP/mcpServerUtils.ts | 55 +++++++- .../ItemDialog/__tests__/McpSection.spec.tsx | 33 ++++- .../Tools/ItemDialog/sections/McpSection.tsx | 68 +++++----- .../Agents/Tools/ToolsMarketplaceDialog.tsx | 4 +- .../__tests__/ToolsMarketplaceDialog.spec.tsx | 6 +- .../MCPBuilder/MCPCardActions.spec.tsx | 55 ++++++++ .../SidePanel/MCPBuilder/MCPCardActions.tsx | 9 +- .../SidePanel/MCPBuilder/MCPServerCard.tsx | 9 +- .../MCPBuilder/MCPStatusBadge.spec.tsx | 43 ++++++ .../SidePanel/MCPBuilder/MCPStatusBadge.tsx | 25 +++- client/src/hooks/MCP/useMCPServerManager.ts | 19 ++- client/src/locales/en/translation.json | 1 + .../src/agents/__tests__/initialize.test.ts | 26 ++++ packages/api/src/agents/discovery.spec.ts | 34 +++++ packages/api/src/agents/discovery.ts | 3 + packages/api/src/agents/handlers.ts | 5 +- packages/api/src/agents/initialize.ts | 18 ++- .../api/src/agents/openai/service.spec.ts | 84 ++++++++++++ packages/api/src/agents/openai/service.ts | 23 +++- .../api/src/mcp/__tests__/request.test.ts | 51 ++++++- .../mcp/__tests__/scope.integration.test.ts | 14 +- packages/api/src/mcp/__tests__/utils.test.ts | 20 +-- packages/api/src/mcp/request.ts | 29 +++- packages/api/src/mcp/types/index.ts | 3 + packages/api/src/mcp/utils.ts | 50 +++---- .../api/src/stream/GenerationJobManager.ts | 1 + .../stream/__tests__/RedisJobStore.spec.ts | 15 +++ .../api/src/stream/__tests__/startup.spec.ts | 10 ++ .../stream/implementations/RedisJobStore.ts | 1 + .../api/src/stream/interfaces/IJobStore.ts | 4 + packages/api/src/stream/metadata.ts | 3 + packages/api/src/types/stream.ts | 4 + packages/data-provider/src/types/queries.ts | 6 + 63 files changed, 1500 insertions(+), 199 deletions(-) create mode 100644 client/src/components/MCP/MCPServerStatusIcon.spec.tsx create mode 100644 client/src/components/MCP/ServerInitializationSection.spec.tsx create mode 100644 client/src/components/MCP/mcpServerUtils.spec.ts create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.spec.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 276b1f8eca..3d27d23cae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 00e3fc372f..edfab4d286 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -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('_')) { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index b3b900e5c7..6e78d3431f 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -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 = [ diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 5a85c0d224..e0f354e367 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -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, diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 389d58d8eb..85c76d488b 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -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); diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 7428c2287f..f0bd8e2d03 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -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', () => ({ diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index ca27a838fe..3668c564d3 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -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')); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index f3500996d6..b3059be876 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -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, + }), ); }); }); diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index 8fa406d7d6..71c06a2634 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -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' } }), diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index bb9201ab89..db5c9f5f61 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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), diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index edb1a32c9f..d3dbb19b71 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 1dedc6617d..a28a568bd6 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -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, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3fd5c45d3d..021449c7ca 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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, diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index b2a4f450f1..5a8233be68 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -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; diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 99ebff511a..9fcb49a1aa 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -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')); diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 554d110405..32bca863d8 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -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) { diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index dee208f3fb..9483b1535f 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -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, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index ded114cdea..4e2dfd031f 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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) { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 59910aa2a0..927291b6b1 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -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({ diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 15bbecc025..359aa8979b 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -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} userConnections - User-level connections * @param {Set} oauthServers - Set of OAuth servers * @param {{ user?: Partial, userMCPAuthMap?: Record>, loadUserMCPAuthMap?: () => Promise> | 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, }; diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index c73fa2968b..36a1ac57ed 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -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', }); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 81aae5aef9..efe9d535e6 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 7b2cb74542..04e35ca28c 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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 diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 493ef03a93..afb3a1e0df 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -14,6 +14,7 @@ import { useMCPConnectionStatus, useMCPServerManager, } from '~/hooks'; +import { isMCPServerReadyForAgent } from '~/components/MCP/mcpServerUtils'; import { Panel, isEphemeralAgent } from '~/common'; const AgentPanelContext = createContext(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, }); diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a86ca07d70..28d2872217 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -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; diff --git a/client/src/components/MCP/MCPServerStatusIcon.spec.tsx b/client/src/components/MCP/MCPServerStatusIcon.spec.tsx new file mode 100644 index 0000000000..442ed57a6e --- /dev/null +++ b/client/src/components/MCP/MCPServerStatusIcon.spec.tsx @@ -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'>) => , + TooltipAnchor: ({ render }: { render: React.ReactNode }) => render, + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: React.ComponentProps<'button'> & { variant?: string; size?: string }) => ( + + ), +})); + +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( + , + ); + + 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( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/components/MCP/MCPServerStatusIcon.tsx b/client/src/components/MCP/MCPServerStatusIcon.tsx index 01ee2f6ebb..8d6b0b01bd 100644 --- a/client/src/components/MCP/MCPServerStatusIcon.tsx +++ b/client/src/components/MCP/MCPServerStatusIcon.tsx @@ -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 ; } + // Request-scoped servers can only be connected while serving an MCP request. + if ((connectionState === 'disconnected' || connectionState === 'error') && requestScoped) { + return hasCustomUserVars ? ( + + ) : null; + } + // Disconnected or Error: show connect button (PlugZap icon) if (connectionState === 'disconnected' || connectionState === 'error') { return ; @@ -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) { diff --git a/client/src/components/MCP/ServerInitializationSection.spec.tsx b/client/src/components/MCP/ServerInitializationSection.spec.tsx new file mode 100644 index 0000000000..d2281f442b --- /dev/null +++ b/client/src/components/MCP/ServerInitializationSection.spec.tsx @@ -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'>) => , + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: React.ComponentProps<'button'> & { variant?: string; size?: string }) => ( + + ), +})); + +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( + , + ); + + expect(screen.queryByRole('button', { name: 'com_ui_mcp_initialize' })).not.toBeInTheDocument(); + + mockConnectionStatus.mockReturnValue({ + connectionState: 'disconnected', + requiresOAuth: false, + requestScoped: true, + configurationState: 'configured', + }); + rerender( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_mcp_initialize' })); + expect(mockInitializeServer).toHaveBeenCalledWith('server', false); + }); +}); diff --git a/client/src/components/MCP/ServerInitializationSection.tsx b/client/src/components/MCP/ServerInitializationSection.tsx index bbed167da6..f42aa15a24 100644 --- a/client/src/components/MCP/ServerInitializationSection.tsx +++ b/client/src/components/MCP/ServerInitializationSection.tsx @@ -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 (
- {requiresOAuth && revokeOAuthForServer && ( + {shouldShowRevoke && ( )} - + {(shouldShowReinit || shouldShowInit) && ( + + )}
); } diff --git a/client/src/components/MCP/mcpServerUtils.spec.ts b/client/src/components/MCP/mcpServerUtils.spec.ts new file mode 100644 index 0000000000..8425de838a --- /dev/null +++ b/client/src/components/MCP/mcpServerUtils.spec.ts @@ -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); + }); +}); diff --git a/client/src/components/MCP/mcpServerUtils.ts b/client/src/components/MCP/mcpServerUtils.ts index 7cd5f2d009..47320a3ac7 100644 --- a/client/src/components/MCP/mcpServerUtils.ts +++ b/client/src/components/MCP/mcpServerUtils.ts @@ -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. diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx index eac90450d2..8b03aa2443 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx @@ -8,6 +8,7 @@ const mockSetValue = jest.fn(); const mockGetValues = jest.fn((): string[] => []); const mockGetToolOptions = jest.fn((): Record | undefined => undefined); const mockMcpServersMap = jest.fn((): Map => 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) => + 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(); + + 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(); 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, diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx index 95248599d6..accc7941b3 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -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(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" /> - {localize(statusDisplay.labelKey)} + {localize(statusDisplay.labelKey, { 0: serverName })} - {isConnected && statusIconProps && } + {isReadyForAgent && statusIconProps && } - {/* 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. */}
@@ -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 && } @@ -614,7 +616,7 @@ export default function McpSection({ item }: Props) { {configDialogProps && } { ); }); - 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: '' }, }, diff --git a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx index 6067c16ff8..e21a057f66 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx @@ -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( + , + ); + + expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument(); + }, + ); + + test('keeps custom-variable configuration available while an on-demand server is idle', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'com_ui_configure' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'com_nav_mcp_connect' })).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx index 6becb23931..14a5770ab0 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx @@ -125,7 +125,7 @@ export default function MCPCardActions({ )} {/* Connect button - for disconnected or error states */} - {(isDisconnected || isError) && ( + {(isDisconnected || isError) && !serverStatus?.requestScoped && ( )} - {/* 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 && ( ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('@librechat/client', () => ({ + Spinner: (props: React.ComponentProps<'span'>) => , +})); + +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(); + + 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(); + + expect(screen.getByRole('status')).toHaveTextContent('com_nav_mcp_status_connecting'); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx b/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx index fd3eefa769..c3306d8b86 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx @@ -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 ( +
+
+ ); + } + // 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'; diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 910bba288b..46718b59bf 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -169,9 +169,26 @@ export function useMCPServerManager({ // Poll intervals are kept local (not serializable) const pollIntervalsRef = useRef({}); - 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) => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 5ad9bdf4d8..8b1b216a02 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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.", diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 8138eb2e04..b517ae4978 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -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 diff --git a/packages/api/src/agents/discovery.spec.ts b/packages/api/src/agents/discovery.spec.ts index 10ee3a5190..dce09b5425 100644 --- a/packages/api/src/agents/discovery.spec.ts +++ b/packages/api/src/agents/discovery.spec.ts @@ -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 diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index 2fb107940e..f525481f23 100644 --- a/packages/api/src/agents/discovery.ts +++ b/packages/api/src/agents/discovery.ts @@ -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, diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 4e0442b4ef..48d0205060 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -78,6 +78,8 @@ export interface ToolExecuteOptions { loadTools: ( toolNames: string[], agentId?: string, + /** Immutable run configuration available before deferred tools connect. */ + configurable?: Record, ) => 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 | 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 | undefined; const loadedConfigurable = toolConfigurable as Record | undefined; const mergedConfigurable = mergeToolConfigurables( sourceConfigurable, diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 9c429832d2..bcc0873319 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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, }); diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index 75bcf09172..bc00e0e3bc 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -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; tenantId?: string; appConfig?: Record; + requestBody?: Record; }; type ProcessStreamConfig = { configurable?: Record }; @@ -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; + }; + 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; + }; + 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 Promise }>; + }; + 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; + }; + 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: { diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index 193e8fa92f..62b7d8df8e 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -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; @@ -191,6 +194,7 @@ type LoadToolsFn = (params: { model: string | null; tool_options: unknown; tool_resources: unknown; + requestBody?: MCPRuntimeRequestBody; }) => Promise<{ tools: unknown[]; toolContextMap: Record; @@ -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 = { ...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 diff --git a/packages/api/src/mcp/__tests__/request.test.ts b/packages/api/src/mcp/__tests__/request.test.ts index e58c972134..47cd41c18f 100644 --- a/packages/api/src/mcp/__tests__/request.test.ts +++ b/packages/api/src/mcp/__tests__/request.test.ts @@ -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']); + }); +}); diff --git a/packages/api/src/mcp/__tests__/scope.integration.test.ts b/packages/api/src/mcp/__tests__/scope.integration.test.ts index 9f49d161de..59fe600f6f 100644 --- a/packages/api/src/mcp/__tests__/scope.integration.test.ts +++ b/packages/api/src/mcp/__tests__/scope.integration.test.ts @@ -51,6 +51,7 @@ interface RequestScopedTestServer { sessionsCreated: () => number; toolCallCount: () => number; observedRunIds: () => string[]; + observedParentMessageIds: () => string[]; } function trackSockets(httpServer: http.Server): () => Promise { @@ -72,6 +73,7 @@ function trackSockets(httpServer: http.Server): () => Promise { async function createRequestScopedTestServer(): Promise { const sessions = new Map(); const runIds: string[] = []; + const parentMessageIds: string[] = []; let created = 0; let deletes = 0; let toolCalls = 0; @@ -87,6 +89,10 @@ async function createRequestScopedTestServer(): Promise 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 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 () => { diff --git a/packages/api/src/mcp/__tests__/utils.test.ts b/packages/api/src/mcp/__tests__/utils.test.ts index 0f85636cc4..53cb788f29 100644 --- a/packages/api/src/mcp/__tests__/utils.test.ts +++ b/packages/api/src/mcp/__tests__/utils.test.ts @@ -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); diff --git a/packages/api/src/mcp/request.ts b/packages/api/src/mcp/request.ts index 68b76027f0..4c2277181f 100644 --- a/packages/api/src/mcp/request.ts +++ b/packages/api/src/mcp/request.ts @@ -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; diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index ae59ed4af4..e823f421b1 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -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; + export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; export type SSEOptions = z.infer; diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index 0abbe46034..ea0d4c0446 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -202,6 +202,11 @@ type PlaceholderValue = | readonly PlaceholderValue[] | { readonly [key: string]: PlaceholderValue }; +export interface MCPRequestScope { + requestScoped: boolean; + requiredBodyFields: Array; +} + 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): void { +function addRuntimeBodyPlaceholderFields( + value: PlaceholderValue, + fields: Set, +): 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(); + 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(); - for (const value of placeholderBearingFields(config)) { - addRuntimeBodyPlaceholderFields(value, fields); - } - return Array.from(fields); +export function getRuntimeBodyPlaceholderFields( + config: UserScopedConnectionConfig, +): Array { + 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; } /** diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 25febf31ab..710a9fe043 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -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, diff --git a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts index ff3be25270..5610d3e59c 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts @@ -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', diff --git a/packages/api/src/stream/__tests__/startup.spec.ts b/packages/api/src/stream/__tests__/startup.spec.ts index e114c8d3c5..227635cd49 100644 --- a/packages/api/src/stream/__tests__/startup.spec.ts +++ b/packages/api/src/stream/__tests__/startup.spec.ts @@ -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', diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index b73a4d7af9..7aa87ebfa5 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -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', diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index e086b51760..a8438b6b60 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -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' diff --git a/packages/api/src/stream/metadata.ts b/packages/api/src/stream/metadata.ts index da8c108b34..d7aade4783 100644 --- a/packages/api/src/stream/metadata.ts +++ b/packages/api/src/stream/metadata.ts @@ -9,6 +9,9 @@ export function sanitizeJobMetadata(metadata: Partial): J if (metadata.isRegenerate !== undefined) { patch.isRegenerate = metadata.isRegenerate; } + if (metadata.mcpRequestBody) { + patch.mcpRequestBody = metadata.mcpRequestBody; + } if (metadata.sender) { patch.sender = metadata.sender; } diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index fca5d8e943..16e8e8124c 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -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 */ diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 2a481d3df5..1078135b42 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -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']; }