@@ -26,6 +26,12 @@ import { addLineNumbers, fileReferenceToString, formatAttachmentsForPrompt, getA
2626import { createTools } from "./tools" ;
2727import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory" ;
2828import { getMcpTools , McpToolsResult } from "@/ee/features/chat/mcp/mcpToolSets" ;
29+ import {
30+ createMcpAuthInterruptionDirective ,
31+ denyApprovedToolApprovalsForAuthInterruption ,
32+ getMcpAuthRequiredFailureFromAssistantMessage ,
33+ McpToolAuthFailure ,
34+ } from "@/ee/features/chat/mcp/mcpAuthFailure" ;
2935import { buildMcpToolRegistry , McpToolRegistryEntry } from "@/ee/features/chat/mcp/mcpToolRegistry" ;
3036import { PromptCacheStrategy , mergeProviderOptions , detectPromptCacheBreak , detectUnexpectedCacheMiss } from "./promptCaching" ;
3137import { hasEntitlement } from '@/lib/entitlements' ;
@@ -332,9 +338,21 @@ export const createMessageStream = async ({
332338 ? ( lastMsg . metadata as SBChatMessageMetadata | undefined )
333339 : undefined ;
334340
341+ // When the response was interrupted by a reconnect-required authentication
342+ // failure (detected via the safe tool error's marker text), the
343+ // continuation must run its final step with tool use disabled. Any
344+ // approval that was still approved is rewritten to a denial: once the
345+ // response is authentication-terminal, later approval actions are invalid.
346+ const priorMcpAuthFailure = hasApprovalContinuationReady
347+ ? getMcpAuthRequiredFailureFromAssistantMessage ( lastMsg )
348+ : undefined ;
349+
335350 if ( hasApprovalContinuationReady ) {
351+ const continuationMessage = priorMcpAuthFailure
352+ ? denyApprovedToolApprovalsForAuthInterruption ( lastMsg , priorMcpAuthFailure . serverName )
353+ : lastMsg ;
336354 const fullLastTurn = await convertToModelMessages (
337- [ lastMsg ] ,
355+ [ continuationMessage ] ,
338356 { ignoreIncompleteToolCalls : true }
339357 ) ;
340358 messageHistory = [ ...messageHistory , ...fullLastTurn ] ;
@@ -375,12 +393,22 @@ export const createMessageStream = async ({
375393 data : { modelToolName, rawToolName } ,
376394 } ) ;
377395 } ,
378- onMcpServerFailed : ( serverName ) => {
396+ onMcpServerFailed : ( server ) => {
379397 writer . write ( {
380398 type : 'data-mcp-failed-server' ,
381- data : { serverName } ,
399+ data : server ,
382400 } ) ;
383401 } ,
402+ onMcpAuthRequired : ( failure ) => {
403+ // Transient: consumed live by the client to surface the
404+ // connector reconnect UI, never folded into persisted parts.
405+ writer . write ( {
406+ type : 'data-mcp-auth-required' ,
407+ data : failure ,
408+ transient : true ,
409+ } ) ;
410+ } ,
411+ priorMcpAuthFailure,
384412 traceId,
385413 chatId,
386414 prisma,
@@ -508,7 +536,14 @@ interface AgentOptions {
508536 onWriteSource : ( source : Source ) => void ;
509537 onMcpServerDiscovered : ( sanitizedName : string , faviconUrl : string ) => void ;
510538 onMcpToolDiscovered : ( modelToolName : string , rawToolName : string ) => void ;
511- onMcpServerFailed : ( serverName : string ) => void ;
539+ onMcpServerFailed : ( server : { serverId : string ; serverName : string } ) => void ;
540+ // Fired at most once per connector per response when a tool call fails
541+ // with a reconnect-required authentication failure.
542+ onMcpAuthRequired : ( failure : McpToolAuthFailure ) => void ;
543+ // Set when the incoming messages show this response was already
544+ // interrupted by an authentication failure (approval continuation): the
545+ // stream must run its final step with tool use disabled from step one.
546+ priorMcpAuthFailure ?: { serverName : string } ;
512547 traceId : string ;
513548 chatId : string ;
514549 prisma : PrismaClient ;
@@ -529,6 +564,8 @@ const createAgentStream = async ({
529564 onMcpServerDiscovered,
530565 onMcpToolDiscovered,
531566 onMcpServerFailed,
567+ onMcpAuthRequired,
568+ priorMcpAuthFailure,
532569 traceId,
533570 chatId,
534571 prisma,
@@ -564,6 +601,15 @@ const createAgentStream = async ({
564601 } ) )
565602 ) . filter ( ( source ) => source !== undefined ) ;
566603
604+ // Mutable, response-scoped authentication failure state. `serverName` is
605+ // the first failed connector's display name (V1 supports recovery for a
606+ // single failed connector). Failures are deduplicated by connector so the
607+ // client sees at most one transient event per connector per response.
608+ const mcpAuthFailureState : { failure ?: { serverName : string } } = {
609+ ...( priorMcpAuthFailure ? { failure : { serverName : priorMcpAuthFailure . serverName } } : { } ) ,
610+ } ;
611+ const reportedMcpAuthFailureServerIds = new Set < string > ( ) ;
612+
567613 let mcpToolSetsObj : McpToolsResult = { tools : { } , failedServers : [ ] , serverFaviconUrls : { } , toolDisplayNames : { } , cleanup : async ( ) => { } } ;
568614 if ( userId && orgId && await hasEntitlement ( 'ask' ) && disabledMcpServerIds !== undefined ) {
569615 try {
@@ -573,6 +619,16 @@ const createAgentStream = async ({
573619 chatId,
574620 traceId,
575621 source : 'sourcebot-ask-agent' ,
622+ } , {
623+ onAuthFailure : ( failure ) => {
624+ if ( ! mcpAuthFailureState . failure ) {
625+ mcpAuthFailureState . failure = { serverName : failure . serverName } ;
626+ }
627+ if ( ! reportedMcpAuthFailureServerIds . has ( failure . serverId ) ) {
628+ reportedMcpAuthFailureServerIds . add ( failure . serverId ) ;
629+ onMcpAuthRequired ( failure ) ;
630+ }
631+ } ,
576632 } ) ;
577633
578634 for ( const [ sanitizedName , faviconUrl ] of Object . entries ( mcpToolSetsObj . serverFaviconUrls ) ) {
@@ -590,8 +646,8 @@ const createAgentStream = async ({
590646 }
591647 }
592648
593- for ( const serverName of mcpToolSetsObj . failedServers ) {
594- onMcpServerFailed ( serverName ) ;
649+ for ( const server of mcpToolSetsObj . failedServers ) {
650+ onMcpServerFailed ( server ) ;
595651 }
596652
597653 const mcpRegistry = buildMcpToolRegistry ( mcpToolSetsObj . tools ) ;
@@ -715,14 +771,34 @@ const createAgentStream = async ({
715771 // rebuilds the step's messages each time as the original input plus
716772 // its own accumulated response messages. Re-applying the moving tail marker
717773 // to the new last message each step is safe and does not accumulate.
718- prepareStep : ( tailMarker || hasMcpTools ) ? ( { steps, messages } ) => {
774+ prepareStep : ( tailMarker || hasMcpTools || mcpAuthFailureState . failure ) ? ( { steps, messages } ) => {
719775 const stepMessages = ( tailMarker && messages . length > 0 )
720776 ? messages . map ( ( message , index ) =>
721777 index === messages . length - 1
722778 ? { ...message , providerOptions : mergeProviderOptions ( message . providerOptions , tailMarker ) }
723779 : message )
724780 : undefined ;
725781
782+ // Once a reconnect-required authentication failure occurs, the
783+ // response is terminal for tool use: every remaining step runs
784+ // with tool calling disabled (`toolChoice: 'none'` keeps the
785+ // tool definitions byte-stable for prompt caching) plus an
786+ // ephemeral directive to summarize completed work and prompt
787+ // the user to reconnect. In-flight tool calls of the failing
788+ // step have already run to completion by the time this fires.
789+ if ( mcpAuthFailureState . failure ) {
790+ return {
791+ messages : [
792+ ...( stepMessages ?? messages ) ,
793+ {
794+ role : 'user' as const ,
795+ content : createMcpAuthInterruptionDirective ( mcpAuthFailureState . failure . serverName ) ,
796+ } ,
797+ ] ,
798+ toolChoice : 'none' as const ,
799+ } ;
800+ }
801+
726802 if ( ! hasMcpTools ) {
727803 return stepMessages ? { messages : stepMessages } : { } ;
728804 }
0 commit comments