1+ const { EventEmitter } = require ( 'events' ) ;
2+
13const mockLogger = {
24 debug : jest . fn ( ) ,
35 warn : jest . fn ( ) ,
@@ -21,6 +23,58 @@ const mockFilterPersistableAbortContent = jest.fn((content) =>
2123const mockGetConvo = jest . fn ( ) ;
2224const mockGetMessages = jest . fn ( ) ;
2325const mockSaveMessage = jest . fn ( ) ;
26+ let mockMCPContexts = new WeakMap ( ) ;
27+
28+ const mockCreateMCPRequestContext = jest . fn ( ( ) => ( {
29+ connections : new Map ( ) ,
30+ pending : new Map ( ) ,
31+ cleanupStarted : false ,
32+ cleanupOnResponse : false ,
33+ responseCleanupAttached : false ,
34+ } ) ) ;
35+ const mockGetMCPRequestContext = jest . fn ( ( req ) => {
36+ if ( ! req ) {
37+ return undefined ;
38+ }
39+
40+ let context = mockMCPContexts . get ( req ) ;
41+ if ( ! context ) {
42+ context = mockCreateMCPRequestContext ( ) ;
43+ mockMCPContexts . set ( req , context ) ;
44+ }
45+
46+ return context . cleanupStarted ? undefined : context ;
47+ } ) ;
48+ const mockCleanupMCPRequestContext = jest . fn ( async ( context ) => {
49+ if ( ! context || context . cleanupStarted ) {
50+ return ;
51+ }
52+
53+ context . cleanupStarted = true ;
54+ const connections = new Set ( context . connections . values ( ) ) ;
55+ const settled = await Promise . allSettled ( context . pending . values ( ) ) ;
56+ for ( const result of settled ) {
57+ if ( result . status === 'fulfilled' && result . value ) {
58+ connections . add ( result . value ) ;
59+ }
60+ }
61+
62+ await Promise . allSettled ( Array . from ( connections ) . map ( ( connection ) => connection . disconnect ?. ( ) ) ) ;
63+ context . connections . clear ( ) ;
64+ context . pending . clear ( ) ;
65+ } ) ;
66+ const mockCleanupMCPRequestContextForReq = jest . fn ( async ( req ) => {
67+ const context = mockMCPContexts . get ( req ) ;
68+ if ( ! context ) {
69+ return ;
70+ }
71+
72+ try {
73+ await mockCleanupMCPRequestContext ( context ) ;
74+ } finally {
75+ mockMCPContexts . delete ( req ) ;
76+ }
77+ } ) ;
2478
2579jest . mock ( '@librechat/data-schemas' , ( ) => ( {
2680 logger : mockLogger ,
@@ -32,7 +86,11 @@ jest.mock('@librechat/api', () => ({
3286 buildMessageFiles : jest . fn ( ( ) => [ ] ) ,
3387 resolveTitleTiming : jest . fn ( ( ) => 'immediate' ) ,
3488 GenerationJobManager : mockGenerationJobManager ,
89+ cleanupMCPRequestContext : ( ...args ) => mockCleanupMCPRequestContext ( ...args ) ,
90+ createMCPRequestContext : ( ...args ) => mockCreateMCPRequestContext ( ...args ) ,
91+ getMCPRequestContext : ( ...args ) => mockGetMCPRequestContext ( ...args ) ,
3592 filterPersistableAbortContent : ( ...args ) => mockFilterPersistableAbortContent ( ...args ) ,
93+ cleanupMCPRequestContextForReq : ( ...args ) => mockCleanupMCPRequestContextForReq ( ...args ) ,
3694 decrementPendingRequest : ( ...args ) => mockDecrementPendingRequest ( ...args ) ,
3795 sanitizeMessageForTransmit : jest . fn ( ( message ) => message ) ,
3896 checkAndIncrementPendingRequest : ( ...args ) => mockCheckAndIncrementPendingRequest ( ...args ) ,
@@ -79,10 +137,33 @@ jest.mock('~/models', () => ({
79137} ) ) ;
80138
81139const AgentController = require ( '../request' ) ;
140+ const { getMCPRequestContext } = require ( '~/server/services/MCPRequestContext' ) ;
141+
142+ function createResumableResponse ( ) {
143+ const res = new EventEmitter ( ) ;
144+ res . headersSent = false ;
145+ res . writableEnded = false ;
146+ res . finished = false ;
147+ res . destroyed = false ;
148+ res . json = jest . fn ( ( ) => {
149+ res . headersSent = true ;
150+ res . writableEnded = true ;
151+ res . finished = true ;
152+ res . emit ( 'finish' ) ;
153+ return res ;
154+ } ) ;
155+ res . status = jest . fn ( ( ) => res ) ;
156+ return res ;
157+ }
158+
159+ function nextTick ( ) {
160+ return new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
161+ }
82162
83163describe ( 'ResumableAgentController resume metadata' , ( ) => {
84164 beforeEach ( ( ) => {
85165 jest . clearAllMocks ( ) ;
166+ mockMCPContexts = new WeakMap ( ) ;
86167 mockCheckAndIncrementPendingRequest . mockResolvedValue ( { allowed : true } ) ;
87168 mockDecrementPendingRequest . mockResolvedValue ( undefined ) ;
88169 mockGetConvo . mockResolvedValue ( { createdAt : '2026-06-07T00:00:00.000Z' } ) ;
@@ -228,6 +309,47 @@ describe('ResumableAgentController resume metadata', () => {
228309 ) ;
229310 } ) ;
230311
312+ it ( 'keeps request-scoped MCP connections until resumable initialization finishes' , async ( ) => {
313+ const conversationId = 'conversation-123' ;
314+ const disconnect = jest . fn ( ) . mockResolvedValue ( undefined ) ;
315+ const initializeClient = jest . fn ( async ( { req, res } ) => {
316+ const context = getMCPRequestContext ( req , res ) ;
317+ context . connections . set ( 'mcp-server' , { disconnect } ) ;
318+
319+ await nextTick ( ) ;
320+ expect ( disconnect ) . not . toHaveBeenCalled ( ) ;
321+
322+ throw new Error ( 'stop after request-scoped MCP connection' ) ;
323+ } ) ;
324+ const req = {
325+ user : { id : 'user-123' } ,
326+ body : {
327+ text : 'Use a BODY-scoped MCP server.' ,
328+ messageId : 'user-message' ,
329+ parentMessageId : 'parent-message' ,
330+ conversationId,
331+ endpointOption : {
332+ endpoint : 'agents' ,
333+ modelOptions : { model : 'gpt-4.1' } ,
334+ } ,
335+ } ,
336+ config : { } ,
337+ } ;
338+ const res = createResumableResponse ( ) ;
339+
340+ await AgentController ( req , res , jest . fn ( ) , initializeClient , null ) ;
341+
342+ expect ( res . json ) . toHaveBeenCalledWith ( {
343+ streamId : conversationId ,
344+ conversationId,
345+ status : 'started' ,
346+ } ) ;
347+ expect ( disconnect ) . toHaveBeenCalledTimes ( 1 ) ;
348+ expect ( disconnect . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
349+ mockDecrementPendingRequest . mock . invocationCallOrder [ 0 ] ,
350+ ) ;
351+ } ) ;
352+
231353 it ( 'stores model spec icon fallbacks and agent ids in early resume metadata' , async ( ) => {
232354 const conversationId = 'conversation-123' ;
233355 const initializeClient = jest . fn ( ) . mockRejectedValue ( new Error ( 'stop before tool loading' ) ) ;
0 commit comments