11import { expect , mock , beforeEach } from "bun:test"
2- import { Effect , Layer } from "effect"
2+ import { Effect , Fiber , Layer } from "effect"
33import { testEffect } from "../lib/effect"
44
55// Mock UnauthorizedError to match the SDK's class
@@ -23,6 +23,10 @@ let simulateAuthFlow = true
2323let connectSucceedsImmediately = false
2424let serverCapabilities : { tools ?: object ; resources ?: object } = { tools : { } }
2525let listToolsCalls = 0
26+ // altimate_change start — hold the tool listing open so a lifecycle call can land mid-authenticate
27+ let listToolsGate : { taken : ( ) => void ; release : Promise < void > } | undefined
28+ let closedClients = 0
29+ // altimate_change end
2630
2731// Mock the transport constructors to simulate OAuth auto-auth on 401
2832void mock . module ( "@modelcontextprotocol/sdk/client/streamableHttp.js" , ( ) => ( {
@@ -101,14 +105,26 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
101105
102106 async listTools ( ) {
103107 listToolsCalls ++
108+ // altimate_change start — see listToolsGate
109+ const gate = listToolsGate
110+ listToolsGate = undefined
111+ if ( gate ) {
112+ gate . taken ( )
113+ await gate . release
114+ }
115+ // altimate_change end
104116 return { tools : [ { name : "test_tool" , inputSchema : { type : "object" , properties : { } } } ] }
105117 }
106118
107119 async listResources ( ) {
108120 return { resources : [ { name : "docs" , uri : "docs://readme" } ] }
109121 }
110122
111- async close ( ) { }
123+ // altimate_change start — see listToolsGate
124+ async close ( ) {
125+ closedClients ++
126+ }
127+ // altimate_change end
112128 } ,
113129} ) )
114130
@@ -123,6 +139,10 @@ beforeEach(() => {
123139 connectSucceedsImmediately = false
124140 serverCapabilities = { tools : { } }
125141 listToolsCalls = 0
142+ // altimate_change start — see listToolsGate
143+ listToolsGate = undefined
144+ closedClients = 0
145+ // altimate_change end
126146} )
127147
128148// Import modules after mocking
@@ -274,3 +294,35 @@ mcpTest.instance(
274294 ) ,
275295 { config : config ( "test-oauth-resources" ) } ,
276296)
297+
298+ // altimate_change start — the already-authorized path of authenticate() commits a client after
299+ // its own async listing; a remove that lands during the listing is the later call, and wins. (review)
300+ mcpTest . instance (
301+ "a server removed while authenticate() lists its tools stays removed, and the late client is closed" ,
302+ ( ) =>
303+ MCP . Service . use ( ( mcp ) =>
304+ Effect . gen ( function * ( ) {
305+ yield * mcp . add ( "test-oauth-removed" , { type : "remote" , url : "https://example.com/mcp" } )
306+
307+ simulateAuthFlow = false
308+ connectSucceedsImmediately = true
309+ let taken ! : ( ) => void
310+ const wasTaken = new Promise < void > ( ( resolve ) => ( taken = resolve ) )
311+ let release ! : ( ) => void
312+ listToolsGate = { taken, release : new Promise < void > ( ( resolve ) => ( release = resolve ) ) }
313+ const closedBefore = closedClients
314+
315+ const authenticating = yield * Effect . forkChild ( mcp . authenticate ( "test-oauth-removed" ) )
316+ yield * Effect . promise ( ( ) => wasTaken )
317+ yield * mcp . remove ( "test-oauth-removed" )
318+ release ( )
319+ yield * Fiber . join ( authenticating )
320+
321+ expect ( ( yield * mcp . status ( ) ) [ "test-oauth-removed" ] ?. status ) . not . toBe ( "connected" )
322+ expect ( ( yield * mcp . clients ( ) ) [ "test-oauth-removed" ] ) . toBeUndefined ( )
323+ expect ( closedClients ) . toBe ( closedBefore + 1 )
324+ } ) ,
325+ ) ,
326+ { config : config ( "test-oauth-removed" ) } ,
327+ )
328+ // altimate_change end
0 commit comments