@@ -25,7 +25,7 @@ import { BusEvent } from "../bus/bus-event"
2525import { Bus } from "@/bus"
2626import { TuiEvent } from "@/cli/cmd/tui/event"
2727import open from "open"
28- import { Effect , Exit , Layer , Option , Context , Schema , Stream } from "effect"
28+ import { Effect , Exit , Layer , Option , Context , Schema , Stream , Schedule , Duration } from "effect"
2929import { EffectBridge } from "@/effect/bridge"
3030import { InstanceState } from "@/effect/instance-state"
3131import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
@@ -119,11 +119,49 @@ function remoteURL(key: string, value: string) {
119119 log . warn ( "invalid remote mcp url" , { key } )
120120}
121121
122- // Convert MCP tool definition to AI SDK Tool type
123- function convertMcpTool ( mcpTool : MCPToolDef , client : MCPClient , timeout ?: number ) : Tool {
122+ // --- Transport error detection (inline to avoid SDK import issues) ---
123+ const TRANSPORT_ERROR_CODES = new Set ( [
124+ "ECONNRESET" , "ECONNREFUSED" , "ETIMEDOUT" , "EHOSTUNREACH" , "ENOTFOUND" ,
125+ "EPIPE" , "ECONNABORTED" , "UND_ERR_SOCKET" , "UND_ERR_CLOSED" ,
126+ "ConnectionRefused" , "ConnectionReset" , "ConnectionAborted" ,
127+ "ConnectionClosed" , "Timeout" , "SocketClosed" , "NotConnected" ,
128+ "FailedToOpenSocket" ,
129+ ] )
130+
131+ function isStreamableHTTPError ( e : unknown ) : e is Error & { code : number | undefined } {
132+ return e instanceof Error && "code" in e && ( typeof ( e as any ) . code === "number" || typeof ( e as any ) . code === "undefined" )
133+ }
134+
135+ function isTransportError ( e : unknown ) : boolean {
136+ if ( isStreamableHTTPError ( e ) ) {
137+ if ( e . code === - 1 ) return true
138+ if ( typeof e . code !== "number" ) return false
139+ if ( e . code === 401 || e . code === 403 ) return false
140+ return e . code >= 400
141+ }
142+ if ( ! ( e instanceof Error ) ) return false
143+ const err = e as Error & { code ?: string ; cause ?: { code ?: string } }
144+ if ( err . cause ?. code && TRANSPORT_ERROR_CODES . has ( err . cause . code ) ) return true
145+ if ( err . code && TRANSPORT_ERROR_CODES . has ( err . code ) ) return true
146+ if ( err . message . includes ( "fetch failed" ) ) return true
147+ if ( err . message . includes ( "Unable to connect" ) ) return true
148+ return false
149+ }
150+
151+ // --- State reference - declared early so it's available to async callbacks ---
152+ let mcpStateRef : InstanceState . InstanceState < State > | undefined
153+
154+ // Convert MCP tool definition to AI SDK Tool type with auto-reconnect on transport errors
155+ function makeTool (
156+ clientName : string ,
157+ mcpTool : MCPToolDef ,
158+ client : MCPClient ,
159+ bridge : EffectBridge . Shape ,
160+ reconnectClient : ( name : string ) => Promise < boolean > ,
161+ timeout ?: number ,
162+ ) : Tool {
124163 const inputSchema = mcpTool . inputSchema
125164
126- // Spread first, then override type to ensure it's always "object"
127165 const schema : JSONSchema7 = {
128166 ...( inputSchema as JSONSchema7 ) ,
129167 type : "object" ,
@@ -134,18 +172,28 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
134172 return dynamicTool ( {
135173 description : mcpTool . description ?? "" ,
136174 inputSchema : jsonSchema ( schema ) ,
137- execute : async ( args : unknown ) => {
138- return client . callTool (
139- {
140- name : mcpTool . name ,
141- arguments : ( args || { } ) as Record < string , unknown > ,
142- } ,
143- CallToolResultSchema ,
144- {
145- resetTimeoutOnProgress : true ,
146- timeout,
147- } ,
148- )
175+ execute : ( args : unknown ) => {
176+ const payload = {
177+ name : mcpTool . name ,
178+ arguments : ( args || { } ) as Record < string , unknown > ,
179+ }
180+ const opts = { resetTimeoutOnProgress : true , timeout }
181+ return client . callTool ( payload , CallToolResultSchema , opts ) . catch ( async ( e ) => {
182+ if ( ! isTransportError ( e ) ) throw e
183+ log . warn ( "mcp transport error, attempting reconnect" , {
184+ clientName,
185+ tool : mcpTool . name ,
186+ error : e instanceof Error ? e . message : String ( e ) ,
187+ } )
188+ const ok = await reconnectClient ( clientName )
189+ if ( ! ok ) throw e
190+ const state = mcpStateRef
191+ if ( ! state ) throw e
192+ const next = await bridge . promise ( InstanceState . get ( state ) )
193+ const fresh = next . clients [ clientName ]
194+ if ( ! fresh || next . status [ clientName ] ?. status !== "connected" ) throw e
195+ return fresh . callTool ( payload , CallToolResultSchema , opts )
196+ } )
149197 } ,
150198 } )
151199}
@@ -444,6 +492,61 @@ export const layer = Layer.effect(
444492 return { mcpClient, status, defs : listed } satisfies CreateResult
445493 } )
446494 const cfgSvc = yield * Config . Service
495+ const layerBridge = yield * EffectBridge . make ( )
496+ const reconnecting = new Map < string , Promise < boolean > > ( )
497+
498+ // Single-flight reconnect: concurrent tool calls for the same MCP name
499+ // share one in-flight Promise instead of each triggering a new connect.
500+ // The entry is removed on both success and failure.
501+ const reconnectClient = ( name : string ) : Promise < boolean > => {
502+ const existing = reconnecting . get ( name )
503+ if ( existing ) return existing
504+ const p = layerBridge
505+ . promise ( getMcpConfig ( name ) )
506+ . then ( ( mcp ) => {
507+ if ( ! mcp ) return false
508+ return layerBridge
509+ . promise ( createAndStore ( name , { ...mcp , enabled : true } ) )
510+ . then ( ( status ) => status . status === "connected" )
511+ } )
512+ . catch ( ( err ) => {
513+ log . error ( "mcp reconnect failed" , { name, error : err instanceof Error ? err . message : String ( err ) } )
514+ return false
515+ } )
516+ . finally ( ( ) => {
517+ reconnecting . delete ( name )
518+ } )
519+ reconnecting . set ( name , p )
520+ return p
521+ }
522+
523+ // Periodic health-check: attempt to reconnect failed servers every 30 seconds.
524+ // Uses a scoped fiber so it is cleaned up when the instance is disposed.
525+ const startHealthCheck = Effect . fn ( "MCP.healthCheck" ) ( function * ( ) {
526+ const s = yield * InstanceState . get ( mcpStateRef ! )
527+ const cfg = yield * cfgSvc . get ( )
528+ const config = cfg . mcp ?? { }
529+ const failedServers = Object . entries ( s . status ) . filter (
530+ ( [ name , st ] ) => st . status === "failed" && config [ name ] && isMcpConfigured ( config [ name ] ) ,
531+ )
532+ if ( failedServers . length > 0 ) {
533+ log . info ( "mcp health-check: attempting reconnect for failed servers" , {
534+ servers : failedServers . map ( ( [ name ] ) => name ) ,
535+ } )
536+ }
537+ for ( const [ name ] of failedServers ) {
538+ // Skip if already reconnecting
539+ if ( reconnecting . has ( name ) ) continue
540+ const mcp = config [ name ]
541+ if ( ! mcp ) continue
542+ const ok = yield * Effect . promise ( ( ) => reconnectClient ( name ) )
543+ if ( ok ) {
544+ log . info ( "mcp health-check: reconnected" , { server : name } )
545+ } else {
546+ log . debug ( "mcp health-check: reconnect still failed" , { server : name } )
547+ }
548+ }
549+ } )
447550
448551 const descendants = Effect . fnUntraced (
449552 function * ( pid : number ) {
@@ -521,7 +624,14 @@ export const layer = Layer.effect(
521624 return
522625 }
523626
524- const result = yield * create ( key , mcp ) . pipe ( Effect . catch ( ( ) => Effect . void ) )
627+ const result = yield * create ( key , mcp ) . pipe (
628+ Effect . catch ( ( err : unknown ) => {
629+ const msg = err instanceof Error ? err . message : String ( err )
630+ log . error ( "mcp server initialization failed, marking as failed" , { key, error : msg } )
631+ s . status [ key ] = { status : "failed" , error : msg }
632+ return Effect . void
633+ } ) ,
634+ )
525635 if ( ! result ) return
526636
527637 s . status [ key ] = result . status
@@ -557,9 +667,17 @@ export const layer = Layer.effect(
557667 } ) ,
558668 )
559669
670+ // Start periodic health-check for failed MCP servers (auto-reconnect every 30s)
671+ yield * startHealthCheck ( ) . pipe (
672+ Effect . catchCause ( ( ) => Effect . void ) ,
673+ Effect . repeat ( Schedule . spaced ( Duration . seconds ( 30 ) ) ) ,
674+ Effect . forkScoped ,
675+ )
676+
560677 return s
561678 } ) ,
562679 )
680+ mcpStateRef = state
563681
564682 function closeClient ( s : State , name : string ) {
565683 const client = s . clients [ name ]
@@ -667,7 +785,14 @@ export const layer = Layer.effect(
667785
668786 const timeout = entry ?. timeout ?? defaultTimeout
669787 for ( const mcpTool of listed ) {
670- result [ sanitize ( clientName ) + "_" + sanitize ( mcpTool . name ) ] = convertMcpTool ( mcpTool , client , timeout )
788+ result [ sanitize ( clientName ) + "_" + sanitize ( mcpTool . name ) ] = makeTool (
789+ clientName ,
790+ mcpTool ,
791+ client ,
792+ layerBridge ,
793+ reconnectClient ,
794+ timeout ,
795+ )
671796 }
672797 } ) ,
673798 { concurrency : "unbounded" } ,
0 commit comments