@@ -49,11 +49,12 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
4949 const { mutateAsync : startOauth } = useStartMcpOauth ( )
5050
5151 const [ connectingServers , setConnectingServers ] = useState < Set < string > > ( ( ) => new Set ( ) )
52- // serverId -> safety timeout. This is the correlation source of truth for the
53- // BroadcastChannel gate: it is cleared only when the flow completes or times out, never by
54- // popup.closed polling — COOP can make popup.closed misreport, and clearing it early would
55- // drop the genuine completion this fix exists to deliver.
56- const pendingFlowsRef = useRef < Map < string , number > > ( new Map ( ) )
52+ // OAuth `state` nonce -> { serverId, safety timeout }. The state keys the BroadcastChannel
53+ // correlation: the callback echoes it on every result (even failures that can't resolve a
54+ // serverId), so the tab that started this exact flow matches it while other same-origin tabs
55+ // ignore it. Cleared only when the flow completes or times out, never by popup.closed polling
56+ // — COOP can make popup.closed misreport, and clearing early would drop a genuine completion.
57+ const pendingFlowsRef = useRef < Map < string , { serverId : string ; timeout : number } > > ( new Map ( ) )
5758 // serverId -> popup.closed poll. Best-effort fast "Connecting…" clear when the user
5859 // abandons the popup; never used to correlate a result.
5960 const popupPollsRef = useRef < Map < string , number > > ( new Map ( ) )
@@ -75,14 +76,15 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
7576 }
7677 } , [ ] )
7778
78- /** End a flow entirely: stop the spinner, its safety timeout, and its popup poll. */
79+ /** End a flow entirely (by its state nonce) : stop the spinner, safety timeout, and popup poll. */
7980 const settleFlow = useCallback (
80- ( serverId : string ) => {
81- const timeout = pendingFlowsRef . current . get ( serverId )
82- if ( timeout !== undefined ) window . clearTimeout ( timeout )
83- pendingFlowsRef . current . delete ( serverId )
84- stopPopupPoll ( serverId )
85- stopConnecting ( serverId )
81+ ( state : string ) => {
82+ const flow = pendingFlowsRef . current . get ( state )
83+ if ( ! flow ) return
84+ window . clearTimeout ( flow . timeout )
85+ pendingFlowsRef . current . delete ( state )
86+ stopPopupPoll ( flow . serverId )
87+ stopConnecting ( flow . serverId )
8688 } ,
8789 [ stopConnecting , stopPopupPoll ]
8890 )
@@ -91,7 +93,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
9193 const pending = pendingFlowsRef . current
9294 const polls = popupPollsRef . current
9395 return ( ) => {
94- for ( const t of pending . values ( ) ) window . clearTimeout ( t )
96+ for ( const { timeout } of pending . values ( ) ) window . clearTimeout ( timeout )
9597 for ( const p of polls . values ( ) ) window . clearInterval ( p )
9698 pending . clear ( )
9799 polls . clear ( )
@@ -107,15 +109,18 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
107109 channel . onmessage = ( event ) => {
108110 const data = event . data as Partial < McpOauthCallbackMessage > | null
109111 if ( data ?. type !== 'mcp-oauth' ) return
110- // A BroadcastChannel reaches every same-origin tab, so react only to a result for a
111- // flow THIS tab started. A result without a serverId can't be correlated to a flow, so
112- // it's ignored here — the initiating tab's safety timeout clears its own "Connecting…".
113- if ( ! data . serverId || ! pendingFlowsRef . current . has ( data . serverId ) ) return
114- settleFlow ( data . serverId )
112+ // A BroadcastChannel reaches every same-origin tab, so react only to a result for a flow
113+ // THIS tab started, matched on the OAuth `state` nonce. Every result (success or failure)
114+ // carries it, so unrelated tabs — and unrelated flows in this tab — ignore the broadcast.
115+ if ( ! data . state ) return
116+ const flow = pendingFlowsRef . current . get ( data . state )
117+ if ( ! flow ) return
118+ const { serverId } = flow
119+ settleFlow ( data . state )
115120 if ( data . ok ) {
116121 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( workspaceId ) } )
117122 queryClient . invalidateQueries ( {
118- queryKey : mcpKeys . serverToolsList ( workspaceId , data . serverId ) ,
123+ queryKey : mcpKeys . serverToolsList ( workspaceId , serverId ) ,
119124 } )
120125 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( workspaceId ) } )
121126 toast . success ( 'Server authorized' )
@@ -132,19 +137,19 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
132137 try {
133138 const result = await startOauth ( { serverId, workspaceId } )
134139 if ( result . status === 'already_authorized' ) {
135- settleFlow ( serverId )
140+ stopConnecting ( serverId )
136141 return
137142 }
138- const { popup } = result
139- // Track this in-flight flow for the BroadcastChannel gate, bounded by a safety
140- // timeout in case no result ever arrives (popup abandoned, or a callback failure
141- // that couldn 't carry a serverId ).
142- const existingTimeout = pendingFlowsRef . current . get ( serverId )
143- if ( existingTimeout !== undefined ) window . clearTimeout ( existingTimeout )
144- pendingFlowsRef . current . set (
143+ const { popup, state } = result
144+ // Track this in-flight flow keyed by its `state` nonce for the BroadcastChannel gate,
145+ // bounded by a safety timeout in case no result ever arrives (popup abandoned, or a
146+ // callback failure the client can 't otherwise clear ).
147+ const existing = pendingFlowsRef . current . get ( state )
148+ if ( existing !== undefined ) window . clearTimeout ( existing . timeout )
149+ pendingFlowsRef . current . set ( state , {
145150 serverId,
146- window . setTimeout ( ( ) => settleFlow ( serverId ) , OAUTH_FLOW_TIMEOUT_MS )
147- )
151+ timeout : window . setTimeout ( ( ) => settleFlow ( state ) , OAUTH_FLOW_TIMEOUT_MS ) ,
152+ } )
148153 // Best-effort: clear "Connecting…" quickly when the user closes the popup without
149154 // finishing. popup.closed can misreport under COOP, so this only stops the spinner —
150155 // it never touches `pendingFlowsRef`, so it can't drop a real result.
@@ -159,7 +164,8 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
159164 } , 500 )
160165 )
161166 } catch ( e ) {
162- settleFlow ( serverId )
167+ stopPopupPoll ( serverId )
168+ stopConnecting ( serverId )
163169 logger . error ( 'Failed to start MCP OAuth' , e )
164170 toast . error ( toError ( e ) . message || 'Failed to start authorization' )
165171 }
0 commit comments