@@ -6,18 +6,32 @@ 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 connectMode : "success" | "missing-fetch" | "failed" | "auth" | "deferred" = "success" ;
1419
1520await withMockedModule (
1621 import . meta. resolve ( "../mcp/client.js" ) ,
1722 ( real : typeof import ( "../mcp/client.js" ) ) => ( {
1823 ...real ,
19- connectMCPServer : async ( config : ResolvedMCPServerConfig ) => {
24+ connectMCPServer : async ( config : ResolvedMCPServerConfig , options : MCPConnectOptions = { } ) => {
2025 connectConfigs . push ( config ) ;
26+ connectOptions . push ( options ) ;
27+ if ( connectMode === "auth" ) {
28+ options . onAuthURL ?.( config . name , "https://auth.test/authorize" ) ;
29+ }
30+ if ( connectMode === "deferred" ) {
31+ await new Promise < void > ( ( resolve ) => {
32+ releaseDeferredConnect = resolve ;
33+ } ) ;
34+ }
2135 if ( connectMode === "failed" ) {
2236 return { ok : false , serverName : config . name , error : "connection exploded" } ;
2337 }
@@ -36,7 +50,9 @@ await withMockedModule(
3650 calls . push ( { toolName, args, signal } ) ;
3751 return "exa fetch result" ;
3852 } ,
39- close : async ( ) => undefined ,
53+ close : async ( ) => {
54+ closedClients . push ( config . name ) ;
55+ } ,
4056 } ,
4157 } ;
4258 } ,
@@ -51,10 +67,13 @@ function permissionGate() {
5167 return createPermissionGate ( { approvals : [ ] , interactive : false , skipPermissions : true } ) ;
5268}
5369
54- async function makeToolset ( mcpServers = resolveMcpServers ( undefined , undefined ) ) {
70+ async function makeToolset (
71+ mcpServers = resolveMcpServers ( undefined , undefined ) ,
72+ gate = permissionGate ( ) ,
73+ ) {
5574 return createAgentToolset ( {
5675 cwd : mkdtempSync ( join ( tmpdir ( ) , "corbits-exa-fetch-alias-" ) ) ,
57- permissionGate : permissionGate ( ) ,
76+ permissionGate : gate ,
5877 onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
5978 mcpServers,
6079 } ) ;
@@ -79,7 +98,10 @@ async function runTool(
7998
8099beforeEach ( ( ) => {
81100 calls . length = 0 ;
101+ closedClients . length = 0 ;
82102 connectConfigs = [ ] ;
103+ connectOptions = [ ] ;
104+ releaseDeferredConnect = undefined ;
83105 connectMode = "success" ;
84106} ) ;
85107
@@ -206,6 +228,197 @@ describe("built-in Exa web_fetch alias", () => {
206228 }
207229 } ) ;
208230
231+ test ( "single-server connection deduplicates and hands OAuth status through" , async ( ) => {
232+ connectMode = "auth" ;
233+ const toolset = await makeToolset (
234+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
235+ ) ;
236+ const states : { state : string ; url ?: string } [ ] = [ ] ;
237+ const callbacks = {
238+ interactiveAuth : true ,
239+ onStatus : ( status : { state : string ; url ?: string } ) => states . push ( status ) ,
240+ onToolsChanged : ( ) => undefined ,
241+ } ;
242+ const server = { name : "linear" , type : "http" as const , url : "https://mcp.linear.app/mcp" } ;
243+ try {
244+ await Promise . all ( [
245+ toolset . connectMCPServer ( server , callbacks ) ,
246+ toolset . connectMCPServer ( server , callbacks ) ,
247+ ] ) ;
248+ await toolset . connectMCPServer ( server , callbacks ) ;
249+
250+ expect ( connectConfigs ) . toEqual ( [ server ] ) ;
251+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [
252+ "connecting" ,
253+ "needs-auth" ,
254+ "connected" ,
255+ ] ) ;
256+ expect ( states [ 1 ] ?. url ) . toBe ( "https://auth.test/authorize" ) ;
257+ expect ( connectOptions [ 0 ] ?. onAuthURL ) . toBeDefined ( ) ;
258+ } finally {
259+ await toolset . dispose ( ) ;
260+ }
261+ } ) ;
262+
263+ test ( "dispose invalidates an in-flight connection and closes its late client" , async ( ) => {
264+ connectMode = "deferred" ;
265+ const toolset = await makeToolset (
266+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
267+ ) ;
268+ const states : string [ ] = [ ] ;
269+ const connection = toolset . connectMCPServer (
270+ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ,
271+ {
272+ interactiveAuth : true ,
273+ onStatus : ( status ) => states . push ( status . state ) ,
274+ onToolsChanged : ( ) => undefined ,
275+ } ,
276+ ) ;
277+ await Promise . resolve ( ) ;
278+
279+ let disposed = false ;
280+ const disposal = toolset . dispose ( ) . then ( ( ) => {
281+ disposed = true ;
282+ } ) ;
283+ await Promise . resolve ( ) ;
284+ expect ( disposed ) . toBe ( false ) ;
285+ releaseDeferredConnect ?.( ) ;
286+ await Promise . all ( [ connection , disposal ] ) ;
287+
288+ expect ( states ) . toEqual ( [ "connecting" ] ) ;
289+ expect ( closedClients ) . toEqual ( [ "linear" ] ) ;
290+ expect (
291+ toolset . dynamicRunner . currentDefinitions ( ) . some ( ( tool ) => tool . name . includes ( "linear" ) ) ,
292+ ) . toBe ( false ) ;
293+ } ) ;
294+
295+ test ( "rejects connected and in-flight implicit Exa names before persistence" , async ( ) => {
296+ const connected = await makeToolset ( ) ;
297+ const connectedPath = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-active-" ) ) , "settings.json" ) ;
298+ try {
299+ await connect ( connected ) ;
300+ expect ( connected . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
301+ expect (
302+ await persistGlobalHTTPMCPServer (
303+ createGlobalSettingsWriter ( connectedPath ) ,
304+ "exa" ,
305+ "https://custom.test/mcp" ,
306+ "none" ,
307+ connected . hasMCPServer ,
308+ ) ,
309+ ) . toEqual ( { ok : false , reason : "active" } ) ;
310+ expect ( await Bun . file ( connectedPath ) . exists ( ) ) . toBe ( false ) ;
311+ } finally {
312+ await connected . dispose ( ) ;
313+ }
314+
315+ connectMode = "deferred" ;
316+ const inFlight = await makeToolset ( ) ;
317+ const inFlightPath = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-active-" ) ) , "settings.json" ) ;
318+ const startup = connect ( inFlight ) ;
319+ while ( releaseDeferredConnect === undefined ) await Promise . resolve ( ) ;
320+ try {
321+ expect ( inFlight . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
322+ expect (
323+ await persistGlobalHTTPMCPServer (
324+ createGlobalSettingsWriter ( inFlightPath ) ,
325+ "exa" ,
326+ "https://custom.test/mcp" ,
327+ "none" ,
328+ inFlight . hasMCPServer ,
329+ ) ,
330+ ) . toEqual ( { ok : false , reason : "active" } ) ;
331+ expect ( await Bun . file ( inFlightPath ) . exists ( ) ) . toBe ( false ) ;
332+ } finally {
333+ releaseDeferredConnect ?.( ) ;
334+ await startup ;
335+ await inFlight . dispose ( ) ;
336+ }
337+ } ) ;
338+
339+ test ( "failed implicit Exa remains reserved and cannot be persisted explicitly" , async ( ) => {
340+ connectMode = "failed" ;
341+ const toolset = await makeToolset ( ) ;
342+ const path = join ( mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-failed-exa-" ) ) , "settings.json" ) ;
343+ try {
344+ await connect ( toolset ) ;
345+ expect ( toolset . hasMCPServer ( "exa" ) ) . toBe ( true ) ;
346+ expect (
347+ await persistGlobalHTTPMCPServer (
348+ createGlobalSettingsWriter ( path ) ,
349+ "exa" ,
350+ "https://custom.test/mcp" ,
351+ "none" ,
352+ toolset . hasMCPServer ,
353+ ) ,
354+ ) . toEqual ( { ok : false , reason : "active" } ) ;
355+ expect ( await Bun . file ( path ) . exists ( ) ) . toBe ( false ) ;
356+ } finally {
357+ await toolset . dispose ( ) ;
358+ }
359+ } ) ;
360+
361+ test ( "single-server registration failure closes the client and reports failed" , async ( ) => {
362+ const gate = permissionGate ( ) ;
363+ gate . registerMcpClient = ( ) => {
364+ throw new Error ( "registration exploded" ) ;
365+ } ;
366+ const toolset = await makeToolset (
367+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
368+ gate ,
369+ ) ;
370+ const states : { state : string ; error ?: string } [ ] = [ ] ;
371+ try {
372+ await toolset . connectMCPServer (
373+ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ,
374+ {
375+ interactiveAuth : true ,
376+ onStatus : ( status ) => states . push ( status ) ,
377+ onToolsChanged : ( ) => undefined ,
378+ } ,
379+ ) ;
380+
381+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [ "connecting" , "failed" ] ) ;
382+ expect ( states [ 1 ] ?. error ) . toContain ( "registration exploded" ) ;
383+ expect ( closedClients ) . toEqual ( [ "linear" ] ) ;
384+ } finally {
385+ await toolset . dispose ( ) ;
386+ }
387+ } ) ;
388+
389+ test ( "connection failure leaves the late-added server persisted and reports failed" , async ( ) => {
390+ connectMode = "failed" ;
391+ const dir = mkdtempSync ( join ( tmpdir ( ) , "corbits-mcp-failure-" ) ) ;
392+ const path = join ( dir , "settings.json" ) ;
393+ const persisted = await persistGlobalHTTPMCPServer (
394+ createGlobalSettingsWriter ( path ) ,
395+ "linear" ,
396+ "https://mcp.linear.app/mcp" ,
397+ ) ;
398+ expect ( persisted . ok ) . toBe ( true ) ;
399+ if ( ! persisted . ok ) return ;
400+
401+ const toolset = await makeToolset (
402+ resolveMcpServers ( [ { name : "exa" , enabled : false } ] , undefined ) ,
403+ ) ;
404+ const states : { state : string ; error ?: string } [ ] = [ ] ;
405+ try {
406+ await toolset . connectMCPServer ( persisted . server , {
407+ interactiveAuth : true ,
408+ onStatus : ( status ) => states . push ( status ) ,
409+ onToolsChanged : ( ) => undefined ,
410+ } ) ;
411+
412+ expect ( states . map ( ( status ) => status . state ) ) . toEqual ( [ "connecting" , "failed" ] ) ;
413+ expect ( states [ 1 ] ?. error ) . toContain ( "connection exploded" ) ;
414+ expect ( await Bun . file ( path ) . json ( ) ) . toMatchObject ( {
415+ mcpServers : [ { name : "linear" , type : "http" , url : "https://mcp.linear.app/mcp" } ] ,
416+ } ) ;
417+ } finally {
418+ await toolset . dispose ( ) ;
419+ }
420+ } ) ;
421+
209422 test ( "child assembly keeps inherited canonical web_fetch and avoids duplicate native fetch" , ( ) => {
210423 const inherited : AgentTool [ ] = [
211424 stringTool ( {
0 commit comments