@@ -6,18 +6,52 @@ import type { ToolResult } from "@intx/types/runtime";
66import { stringTool , type AgentTool } from "@intx/agent" ;
77import { withMockedModule } from "../../tests/helpers/mock-module.js" ;
88import type { ResolvedMCPServerConfig } from "../mcp/exa.js" ;
9+ import type { MCPConnectOptions } from "../mcp/client.js" ;
10+ import { createGlobalSettingsWriter , persistGlobalHTTPMCPServer } from "../mcp/add-server.js" ;
911import { createPermissionGate } from "../permission/gate.js" ;
1012
1113const calls : { toolName : string ; args : Record < string , unknown > ; signal : AbortSignal } [ ] = [ ] ;
14+ const closedClients : string [ ] = [ ] ;
1215let connectConfigs : ResolvedMCPServerConfig [ ] = [ ] ;
13- let connectMode : "success" | "missing-fetch" | "failed" = "success" ;
16+ let connectOptions : MCPConnectOptions [ ] = [ ] ;
17+ let releaseDeferredConnect : ( ( ) => void ) | undefined ;
18+ let authWaitAborts = 0 ;
19+ let authResourceCloses = 0 ;
20+ let blockInteractiveAuth = false ;
21+ let connectMode : "success" | "missing-fetch" | "failed" | "rejected" | "auth" | "deferred" =
22+ "success" ;
1423
1524await withMockedModule (
1625 import . meta. resolve ( "../mcp/client.js" ) ,
1726 ( real : typeof import ( "../mcp/client.js" ) ) => ( {
1827 ...real ,
19- connectMCPServer : async ( config : ResolvedMCPServerConfig ) => {
28+ connectMCPServer : async ( config : ResolvedMCPServerConfig , options : MCPConnectOptions = { } ) => {
2029 connectConfigs . push ( config ) ;
30+ connectOptions . push ( options ) ;
31+ if ( connectMode === "auth" || blockInteractiveAuth ) {
32+ options . onAuthURL ?.( config . name , "https://auth.test/authorize" ) ;
33+ }
34+ if ( blockInteractiveAuth ) {
35+ await new Promise < void > ( ( resolve ) => {
36+ const onAbort = ( ) : void => {
37+ authWaitAborts += 1 ;
38+ authResourceCloses += 1 ;
39+ resolve ( ) ;
40+ } ;
41+ if ( options . signal ?. aborted === true ) {
42+ onAbort ( ) ;
43+ } else {
44+ options . signal ?. addEventListener ( "abort" , onAbort , { once : true } ) ;
45+ }
46+ } ) ;
47+ return { ok : false , serverName : config . name , error : "authorization aborted" } ;
48+ }
49+ if ( connectMode === "deferred" ) {
50+ await new Promise < void > ( ( resolve ) => {
51+ releaseDeferredConnect = resolve ;
52+ } ) ;
53+ }
54+ if ( connectMode === "rejected" ) throw new Error ( "transport setup exploded" ) ;
2155 if ( connectMode === "failed" ) {
2256 return { ok : false , serverName : config . name , error : "connection exploded" } ;
2357 }
@@ -36,7 +70,9 @@ await withMockedModule(
3670 calls . push ( { toolName, args, signal } ) ;
3771 return "exa fetch result" ;
3872 } ,
39- close : async ( ) => undefined ,
73+ close : async ( ) => {
74+ closedClients . push ( config . name ) ;
75+ } ,
4076 } ,
4177 } ;
4278 } ,
@@ -51,10 +87,13 @@ function permissionGate() {
5187 return createPermissionGate ( { approvals : [ ] , interactive : false , skipPermissions : true } ) ;
5288}
5389
54- async function makeToolset ( mcpServers = resolveMcpServers ( undefined , undefined ) ) {
90+ async function makeToolset (
91+ mcpServers = resolveMcpServers ( undefined , undefined ) ,
92+ gate = permissionGate ( ) ,
93+ ) {
5594 return createAgentToolset ( {
5695 cwd : mkdtempSync ( join ( tmpdir ( ) , "corbits-exa-fetch-alias-" ) ) ,
57- permissionGate : permissionGate ( ) ,
96+ permissionGate : gate ,
5897 onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
5998 mcpServers,
6099 } ) ;
@@ -79,7 +118,13 @@ async function runTool(
79118
80119beforeEach ( ( ) => {
81120 calls . length = 0 ;
121+ closedClients . length = 0 ;
82122 connectConfigs = [ ] ;
123+ connectOptions = [ ] ;
124+ releaseDeferredConnect = undefined ;
125+ authWaitAborts = 0 ;
126+ authResourceCloses = 0 ;
127+ blockInteractiveAuth = false ;
83128 connectMode = "success" ;
84129} ) ;
85130
@@ -206,6 +251,281 @@ describe("built-in Exa web_fetch alias", () => {
206251 }
207252 } ) ;
208253
254+ test ( "single-server connection deduplicates and hands OAuth status through" , async ( ) => {
255+ connectMode = "auth" ;
256+ const toolset = await makeToolset (
257+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
258+ ) ;
259+ const states : { state : string ; url ?: string } [ ] = [ ] ;
260+ const callbacks = {
261+ interactiveAuth : true ,
262+ onStatus : ( status : { state : string ; url ?: string } ) => states . push ( status ) ,
263+ onToolsChanged : ( ) => undefined ,
264+ } ;
265+ const server = { name : "linear" , type : "http" as const , url : "https://mcp.linear.app/mcp" } ;
266+ try {
267+ await Promise . all ( [
268+ toolset . connectMCPServer ( server , callbacks ) ,
269+ toolset . connectMCPServer ( server , callbacks ) ,
270+ ] ) ;
271+ await toolset . connectMCPServer ( server , callbacks ) ;
272+
273+ expect ( connectConfigs ) . toEqual ( [ server ] ) ;
274+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [
275+ "connecting" ,
276+ "needs-auth" ,
277+ "connected" ,
278+ ] ) ;
279+ expect ( states [ 1 ] ?. url ) . toBe ( "https://auth.test/authorize" ) ;
280+ expect ( connectOptions [ 0 ] ?. onAuthURL ) . toBeDefined ( ) ;
281+ } finally {
282+ await toolset . dispose ( ) ;
283+ }
284+ } ) ;
285+
286+ test ( "dispose invalidates an in-flight connection and closes its late client" , async ( ) => {
287+ connectMode = "deferred" ;
288+ const toolset = await makeToolset (
289+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
290+ ) ;
291+ const states : string [ ] = [ ] ;
292+ const connection = toolset . connectMCPServer (
293+ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ,
294+ {
295+ interactiveAuth : true ,
296+ onStatus : ( status ) => states . push ( status . state ) ,
297+ onToolsChanged : ( ) => undefined ,
298+ } ,
299+ ) ;
300+ await Promise . resolve ( ) ;
301+
302+ let disposed = false ;
303+ const disposal = toolset . dispose ( ) . then ( ( ) => {
304+ disposed = true ;
305+ } ) ;
306+ await Promise . resolve ( ) ;
307+ expect ( disposed ) . toBe ( false ) ;
308+ releaseDeferredConnect ?.( ) ;
309+ await Promise . all ( [ connection , disposal ] ) ;
310+
311+ expect ( states ) . toEqual ( [ "connecting" ] ) ;
312+ expect ( closedClients ) . toEqual ( [ "linear" ] ) ;
313+ expect (
314+ toolset . dynamicRunner . currentDefinitions ( ) . some ( ( tool ) => tool . name . includes ( "linear" ) ) ,
315+ ) . toBe ( false ) ;
316+ } ) ;
317+
318+ test ( "dispose aborts blocked interactive auth and closes its resources" , async ( ) => {
319+ blockInteractiveAuth = true ;
320+ const toolset = await makeToolset (
321+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
322+ ) ;
323+ const callerAbort = new AbortController ( ) ;
324+ const states : string [ ] = [ ] ;
325+ const connection = toolset . connectMCPServer (
326+ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ,
327+ {
328+ interactiveAuth : true ,
329+ onStatus : ( status ) => states . push ( status . state ) ,
330+ onToolsChanged : ( ) => undefined ,
331+ } ,
332+ callerAbort . signal ,
333+ ) ;
334+ while ( connectOptions . length === 0 ) await Promise . resolve ( ) ;
335+
336+ const ownedSignal = connectOptions [ 0 ] ?. signal ;
337+ expect ( ownedSignal ) . toBeDefined ( ) ;
338+ expect ( ownedSignal ) . not . toBe ( callerAbort . signal ) ;
339+ const disposal = toolset . dispose ( ) ;
340+ expect ( toolset . dispose ( ) ) . toBe ( disposal ) ;
341+ await Promise . resolve ( ) ;
342+ expect ( ownedSignal ?. aborted ) . toBe ( true ) ;
343+ expect ( callerAbort . signal . aborted ) . toBe ( false ) ;
344+ await Promise . all ( [ connection , disposal ] ) ;
345+
346+ expect ( authWaitAborts ) . toBe ( 1 ) ;
347+ expect ( authResourceCloses ) . toBe ( 1 ) ;
348+ expect ( states ) . toEqual ( [ "connecting" , "needs-auth" ] ) ;
349+ expect (
350+ toolset . dynamicRunner . currentDefinitions ( ) . some ( ( tool ) => tool . name . includes ( "linear" ) ) ,
351+ ) . toBe ( false ) ;
352+ } ) ;
353+
354+ test ( "rejects connected and in-flight implicit Exa names before persistence" , async ( ) => {
355+ const connected = await makeToolset ( ) ;
356+ const connectedPath = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-active-" ) ) , "settings.json" ) ;
357+ try {
358+ await connect ( connected ) ;
359+ expect ( connected . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
360+ expect (
361+ await persistGlobalHTTPMCPServer (
362+ createGlobalSettingsWriter ( connectedPath ) ,
363+ "exa" ,
364+ "https://custom.test/mcp" ,
365+ "none" ,
366+ connected . hasMCPServer ,
367+ ) ,
368+ ) . toEqual ( { ok : false , reason : "active" } ) ;
369+ expect ( await Bun . file ( connectedPath ) . exists ( ) ) . toBe ( false ) ;
370+ } finally {
371+ await connected . dispose ( ) ;
372+ }
373+
374+ connectMode = "deferred" ;
375+ const inFlight = await makeToolset ( ) ;
376+ const inFlightPath = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-active-" ) ) , "settings.json" ) ;
377+ const startup = connect ( inFlight ) ;
378+ while ( releaseDeferredConnect === undefined ) await Promise . resolve ( ) ;
379+ try {
380+ expect ( inFlight . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
381+ expect (
382+ await persistGlobalHTTPMCPServer (
383+ createGlobalSettingsWriter ( inFlightPath ) ,
384+ "exa" ,
385+ "https://custom.test/mcp" ,
386+ "none" ,
387+ inFlight . hasMCPServer ,
388+ ) ,
389+ ) . toEqual ( { ok : false , reason : "active" } ) ;
390+ expect ( await Bun . file ( inFlightPath ) . exists ( ) ) . toBe ( false ) ;
391+ } finally {
392+ releaseDeferredConnect ?.( ) ;
393+ await startup ;
394+ await inFlight . dispose ( ) ;
395+ }
396+ } ) ;
397+
398+ test ( "failed implicit Exa remains reserved and cannot be persisted explicitly" , async ( ) => {
399+ connectMode = "failed" ;
400+ const toolset = await makeToolset ( ) ;
401+ const path = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-failed-exa-" ) ) , "settings.json" ) ;
402+ try {
403+ await connect ( toolset ) ;
404+ expect ( toolset . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
405+ expect (
406+ await persistGlobalHTTPMCPServer (
407+ createGlobalSettingsWriter ( path ) ,
408+ "exa" ,
409+ "https://custom.test/mcp" ,
410+ "none" ,
411+ toolset . hasMCPServer ,
412+ ) ,
413+ ) . toEqual ( { ok : false , reason : "active" } ) ;
414+ expect ( await Bun . file ( path ) . exists ( ) ) . toBe ( false ) ;
415+ } finally {
416+ await toolset . dispose ( ) ;
417+ }
418+ } ) ;
419+
420+ test ( "single-server registration failure closes the client and reports failed" , async ( ) => {
421+ const gate = permissionGate ( ) ;
422+ gate . registerMcpClient = ( ) => {
423+ throw new Error ( "registration exploded" ) ;
424+ } ;
425+ const toolset = await makeToolset (
426+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
427+ gate ,
428+ ) ;
429+ const states : { state : string ; error ?: string } [ ] = [ ] ;
430+ try {
431+ await toolset . connectMCPServer (
432+ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ,
433+ {
434+ interactiveAuth : true ,
435+ onStatus : ( status ) => states . push ( status ) ,
436+ onToolsChanged : ( ) => undefined ,
437+ } ,
438+ ) ;
439+
440+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [ "connecting" , "failed" ] ) ;
441+ expect ( states [ 1 ] ?. error ) . toContain ( "registration exploded" ) ;
442+ expect ( closedClients ) . toEqual ( [ "linear" ] ) ;
443+ } finally {
444+ await toolset . dispose ( ) ;
445+ }
446+ } ) ;
447+
448+ test ( "rejected single-server connection reports failed without registration or client leaks" , async ( ) => {
449+ connectMode = "rejected" ;
450+ const gate = permissionGate ( ) ;
451+ let registrations = 0 ;
452+ let unregistrations = 0 ;
453+ gate . registerMcpClient = ( ) => {
454+ registrations += 1 ;
455+ } ;
456+ gate . unregisterMcpServer = ( ) => {
457+ unregistrations += 1 ;
458+ } ;
459+ const toolset = await makeToolset (
460+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
461+ gate ,
462+ ) ;
463+ const server = { name : "linear" , type : "http" as const , url : "https://mcp.linear.app/mcp" } ;
464+ const states : { state : string ; error ?: string } [ ] = [ ] ;
465+ try {
466+ await toolset . connectMCPServer ( server , {
467+ interactiveAuth : true ,
468+ onStatus : ( status ) => states . push ( status ) ,
469+ onToolsChanged : ( ) => undefined ,
470+ } ) ;
471+
472+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [ "connecting" , "failed" ] ) ;
473+ expect ( states [ 1 ] ?. error ) . toContain ( "transport setup exploded" ) ;
474+ expect ( registrations ) . toBe ( 0 ) ;
475+ expect ( unregistrations ) . toBe ( 0 ) ;
476+ expect ( closedClients ) . toEqual ( [ ] ) ;
477+ expect (
478+ toolset . dynamicRunner . currentDefinitions ( ) . some ( ( tool ) => tool . name . includes ( "linear" ) ) ,
479+ ) . toBe ( false ) ;
480+ expect ( toolset . hasMCPServer ( "linear" ) ) . toBe ( true ) ;
481+
482+ connectMode = "failed" ;
483+ const retryStates : string [ ] = [ ] ;
484+ await toolset . connectMCPServer ( server , {
485+ interactiveAuth : true ,
486+ onStatus : ( status ) => retryStates . push ( status . state ) ,
487+ onToolsChanged : ( ) => undefined ,
488+ } ) ;
489+ expect ( retryStates ) . toEqual ( [ "connecting" , "failed" ] ) ;
490+ expect ( connectConfigs ) . toEqual ( [ server , server ] ) ;
491+ } finally {
492+ await toolset . dispose ( ) ;
493+ }
494+ } ) ;
495+
496+ test ( "connection failure leaves the late-added server persisted and reports failed" , async ( ) => {
497+ connectMode = "failed" ;
498+ const dir = mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-failure-" ) ) ;
499+ const path = join ( dir , "settings.json" ) ;
500+ const persisted = await persistGlobalHTTPMCPServer (
501+ createGlobalSettingsWriter ( path ) ,
502+ "linear" ,
503+ "https://mcp.linear.app/mcp" ,
504+ ) ;
505+ expect ( persisted . ok ) . toBe ( true ) ;
506+ if ( ! persisted . ok ) return ;
507+
508+ const toolset = await makeToolset (
509+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
510+ ) ;
511+ const states : { state : string ; error ?: string } [ ] = [ ] ;
512+ try {
513+ await toolset . connectMCPServer ( persisted . server , {
514+ interactiveAuth : true ,
515+ onStatus : ( status ) => states . push ( status ) ,
516+ onToolsChanged : ( ) => undefined ,
517+ } ) ;
518+
519+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [ "connecting" , "failed" ] ) ;
520+ expect ( states [ 1 ] ?. error ) . toContain ( "connection exploded" ) ;
521+ expect ( await Bun . file ( path ) . json ( ) ) . toMatchObject ( {
522+ mcpServers : [ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ] ,
523+ } ) ;
524+ } finally {
525+ await toolset . dispose ( ) ;
526+ }
527+ } ) ;
528+
209529 test ( "child assembly keeps inherited canonical web_fetch and avoids duplicate native fetch" , ( ) => {
210530 const inherited : AgentTool [ ] = [
211531 stringTool ( {
0 commit comments