@@ -168,6 +168,7 @@ export class McpHub {
168168 private initializationPromise : Promise < void >
169169 private secretStorage ?: SecretStorageService
170170 private reauthPromises : Map < string , Promise < void > > = new Map ( )
171+ private _oauthWatchers : Map < string , { unsubscribe : ( ) => void ; abortHandle : NodeJS . Timeout } > = new Map ( )
171172
172173 constructor ( provider : ClineProvider ) {
173174 this . providerRef = new WeakRef ( provider )
@@ -825,6 +826,13 @@ export class McpHub {
825826 const connection = this . findConnection ( name , source )
826827 if ( connection && connection . type === "connected" ) {
827828 if ( error instanceof UnauthorizedError && authProvider ) {
829+ // If we're already in the OAuth / polling flow, ignore transport
830+ // retries that surface another 401 — the poll or _completeOAuthFlow
831+ // will reconnect from scratch once tokens arrive.
832+ if ( connection . server . status === "connecting" ) {
833+ return
834+ }
835+
828836 // Mid-session re-auth triggered by a tool call (401)
829837 connection . server . status = "connecting"
830838
@@ -861,6 +869,12 @@ export class McpHub {
861869 transport . onclose = async ( ) => {
862870 const connection = this . findConnection ( name , source )
863871 if ( connection ) {
872+ // If OAuth is in progress, don't overwrite "connecting" with "disconnected".
873+ // The transport will close/retry while we await the browser flow or poll;
874+ // the reconnect path (deleteConnection + connectToServer) handles cleanup.
875+ if ( connection . server . status === "connecting" ) {
876+ return
877+ }
864878 connection . server . status = "disconnected"
865879 }
866880 await this . notifyWebviewOfServerChanges ( )
@@ -943,21 +957,69 @@ export class McpHub {
943957 try {
944958 await client . connect ( transport )
945959 } catch ( connectError ) {
946- if ( connectError instanceof UnauthorizedError && streamableHttpAuthProvider ) {
947- // The server requires OAuth. The SDK has already called
948- // authProvider.redirectToAuthorization() which started the local callback
949- // server (lazily) and opened the user's browser.
950- //
951- // We fire-and-forget the rest of the flow so the extension (chat window,
952- // other servers) is not blocked waiting for the user's browser session.
960+ if ( connectError instanceof UnauthorizedError && streamableHttpAuthProvider && configInjected . url ) {
961+ // The server requires OAuth. Mark this connection as "connecting" and
962+ // detach the toast + browser flow from the initialization path so that
963+ // waitUntilReady() resolves immediately and the MCP panel can load.
964+ const serverUrl = configInjected . url
953965 connection . server . status = "connecting"
954- void this . _completeOAuthFlow (
955- streamableHttpAuthProvider ,
956- transport as StreamableHTTPClientTransport ,
957- connection ,
958- name ,
959- source ,
960- )
966+
967+ void ( async ( ) => {
968+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000
969+
970+ // Check if another window already saved valid tokens
971+ const existing = await this . secretStorage ! . getOAuthData ( serverUrl )
972+ if ( existing && Date . now ( ) < existing . expires_at - TOKEN_EXPIRY_BUFFER_MS ) {
973+ await streamableHttpAuthProvider . close ( )
974+ await this . deleteConnection ( name , source )
975+ await this . connectToServer ( name , config , source )
976+ await this . notifyWebviewOfServerChanges ( )
977+ return
978+ }
979+
980+ // Show a confirmation toast so the user can decide whether to authenticate.
981+ // This resolves immediately when the user responds — but connectToServer
982+ // has already returned so the panel is not blocked.
983+ const choice = await vscode . window . showInformationMessage (
984+ `MCP server "${ name } " requires authentication.` ,
985+ "Authenticate" ,
986+ )
987+
988+ if ( choice === "Authenticate" ) {
989+ // Check tokens again — another window may have authed while toast was showing
990+ const tokens = await this . secretStorage ! . getOAuthData ( serverUrl )
991+ if ( tokens && Date . now ( ) < tokens . expires_at - TOKEN_EXPIRY_BUFFER_MS ) {
992+ await streamableHttpAuthProvider . close ( )
993+ await this . deleteConnection ( name , source )
994+ await this . connectToServer ( name , config , source )
995+ await this . notifyWebviewOfServerChanges ( )
996+ return
997+ }
998+ void this . _completeOAuthFlow (
999+ streamableHttpAuthProvider ,
1000+ transport as StreamableHTTPClientTransport ,
1001+ connection ,
1002+ name ,
1003+ source ,
1004+ )
1005+ } else {
1006+ // Toast was dismissed or auto-timed-out.
1007+ // First do an immediate check — another window may have already
1008+ // completed auth while the toast was showing.
1009+ const tokens = await this . secretStorage ! . getOAuthData ( serverUrl )
1010+ if ( tokens && Date . now ( ) < tokens . expires_at - TOKEN_EXPIRY_BUFFER_MS ) {
1011+ await streamableHttpAuthProvider . close ( )
1012+ await this . deleteConnection ( name , source )
1013+ await this . connectToServer ( name , config , source )
1014+ await this . notifyWebviewOfServerChanges ( )
1015+ return
1016+ }
1017+ // Tokens not available yet — start watching for them
1018+ await streamableHttpAuthProvider . close ( )
1019+ this . _watchForOAuthTokens ( name , source , serverUrl , config )
1020+ }
1021+ } ) ( )
1022+
9611023 return
9621024 }
9631025 // Non-OAuth error — let the outer catch handle it.
@@ -1010,19 +1072,24 @@ export class McpHub {
10101072 name : string ,
10111073 source : "global" | "project" ,
10121074 ) : Promise < void > {
1075+ const config = JSON . parse ( connection . server . config )
10131076 try {
1077+ // Open the browser now that the user has confirmed the toast.
1078+ // redirectToAuthorization() was already called by the SDK (which stored
1079+ // the URL in _pendingAuthorizationUrl), but deliberately did not open it.
1080+ await authProvider . openBrowser ( )
1081+
10141082 const code = await authProvider . waitForAuthCode ( )
10151083 // Exchange auth code for tokens using the pre-fetched token_endpoint
10161084 // directly. The SDK's transport.finishAuth() re-runs discovery internally
10171085 // and hits the same broken URL for path-prefixed issuers (see
10181086 // utils/oauth.ts for upstream issue links).
10191087 await authProvider . exchangeCodeForTokens ( code )
1020- authProvider . close ( ) . catch ( console . error )
1088+ await authProvider . close ( )
10211089
10221090 // Recover the validated server config stored on the connection so we
10231091 // can pass it directly to connectToServer without re-reading the file.
1024- const parsedConfig = JSON . parse ( connection . server . config )
1025- const validatedConfig = this . validateServerConfig ( parsedConfig , name )
1092+ const validatedConfig = this . validateServerConfig ( config , name )
10261093
10271094 // Remove the broken connection (closes the old transport/client),
10281095 // then reconnect. The new McpOAuthClientProvider will find the token
@@ -1045,6 +1112,82 @@ export class McpHub {
10451112 }
10461113 }
10471114
1115+ private _watchForOAuthTokens (
1116+ name : string ,
1117+ source : "global" | "project" ,
1118+ serverUrl : string ,
1119+ config : z . infer < typeof ServerConfigSchema > ,
1120+ ) : void {
1121+ if ( ! this . secretStorage ) return
1122+
1123+ const watcherKey = `${ name } :${ source } `
1124+
1125+ // Cancel any existing watcher for this connection before starting a new one
1126+ const existing = this . _oauthWatchers . get ( watcherKey )
1127+ if ( existing ) {
1128+ existing . unsubscribe ( )
1129+ clearTimeout ( existing . abortHandle )
1130+ this . _oauthWatchers . delete ( watcherKey )
1131+ }
1132+
1133+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000
1134+
1135+ // Called when SecretStorage fires onDidChange for this server's key.
1136+ // Runs in all VS Code windows the instant tokens are saved — no polling delay.
1137+ const onTokensChanged = async ( ) => {
1138+ try {
1139+ if ( this . isDisposed ) return
1140+
1141+ const conn = this . findConnection ( name , source )
1142+ if ( ! conn || conn . server . status === "connected" ) {
1143+ cleanup ( )
1144+ return
1145+ }
1146+
1147+ const data = await this . secretStorage ?. getOAuthData ( serverUrl )
1148+ if ( data && Date . now ( ) < data . expires_at - TOKEN_EXPIRY_BUFFER_MS ) {
1149+ cleanup ( )
1150+ await this . deleteConnection ( name , source )
1151+ const validatedConfig = this . validateServerConfig ( config , name )
1152+ await this . connectToServer ( name , validatedConfig , source )
1153+ await this . notifyWebviewOfServerChanges ( )
1154+ }
1155+ // If tokens aren't valid yet (e.g. a delete event fired), keep listening.
1156+ } catch ( err ) {
1157+ console . error ( `[McpHub] OAuth token watcher failed for "${ name } ":` , err )
1158+ }
1159+ }
1160+
1161+ const cleanup = ( ) => {
1162+ const entry = this . _oauthWatchers . get ( watcherKey )
1163+ if ( entry ) {
1164+ entry . unsubscribe ( )
1165+ clearTimeout ( entry . abortHandle )
1166+ this . _oauthWatchers . delete ( watcherKey )
1167+ }
1168+ }
1169+
1170+ const unsubscribe = this . secretStorage . onDidChange ( serverUrl , ( ) => {
1171+ void onTokensChanged ( )
1172+ } )
1173+
1174+ // Give up after 6 minutes if no token ever arrives
1175+ const abortHandle = setTimeout (
1176+ ( ) => {
1177+ cleanup ( )
1178+ const conn = this . findConnection ( name , source )
1179+ if ( conn && conn . server . status === "connecting" ) {
1180+ conn . server . status = "disconnected"
1181+ this . appendErrorMessage ( conn , "OAuth authentication timed out waiting for another window" )
1182+ void this . notifyWebviewOfServerChanges ( )
1183+ }
1184+ } ,
1185+ 6 * 60 * 1000 ,
1186+ )
1187+
1188+ this . _oauthWatchers . set ( watcherKey , { unsubscribe, abortHandle } )
1189+ }
1190+
10481191 private appendErrorMessage ( connection : McpConnection , error : string , level : "error" | "warn" | "info" = "error" ) {
10491192 const MAX_ERROR_LENGTH = 1000
10501193 const truncatedError =
@@ -1222,6 +1365,15 @@ export class McpHub {
12221365 }
12231366
12241367 async deleteConnection ( name : string , source ?: "global" | "project" ) : Promise < void > {
1368+ // Cancel any active OAuth token watchers for this connection
1369+ const watcherKey = `${ name } :${ source } `
1370+ const watcher = this . _oauthWatchers . get ( watcherKey )
1371+ if ( watcher ) {
1372+ watcher . unsubscribe ( )
1373+ clearTimeout ( watcher . abortHandle )
1374+ this . _oauthWatchers . delete ( watcherKey )
1375+ }
1376+
12251377 // Clean up file watchers for this server
12261378 this . removeFileWatchersForServer ( name )
12271379
@@ -2181,6 +2333,14 @@ export class McpHub {
21812333 }
21822334
21832335 this . isProgrammaticUpdate = false
2336+
2337+ // Cancel all active OAuth token watchers
2338+ for ( const { unsubscribe, abortHandle } of this . _oauthWatchers . values ( ) ) {
2339+ unsubscribe ( )
2340+ clearTimeout ( abortHandle )
2341+ }
2342+ this . _oauthWatchers . clear ( )
2343+
21842344 this . removeAllFileWatchers ( )
21852345
21862346 for ( const connection of this . connections ) {
0 commit comments