Skip to content

Commit 1ff0ff4

Browse files
committed
feat(web): recover from mid-turn MCP connector auth failures in chat
When a connector tool call fails with a reconnectable auth error, the agent ends tool use for the response, summarizes completed work, and the failed tool result offers inline Reconnect and Continue actions that reuse the existing OAuth flow and return to the exact thread.
1 parent 9e2e32d commit 1ff0ff4

16 files changed

Lines changed: 1640 additions & 6 deletions

packages/web/src/ee/features/chat/agent.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import { addLineNumbers, fileReferenceToString, formatAttachmentsForPrompt, getA
2626
import { createTools } from "./tools";
2727
import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory";
2828
import { 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";
2935
import { buildMcpToolRegistry, McpToolRegistryEntry } from "@/ee/features/chat/mcp/mcpToolRegistry";
3036
import { PromptCacheStrategy, mergeProviderOptions, detectPromptCacheBreak, detectUnexpectedCacheMiss } from "./promptCaching";
3137
import { 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];
@@ -381,6 +399,16 @@ export const createMessageStream = async ({
381399
data: { serverName },
382400
});
383401
},
402+
onMcpAuthRequired: (failure) => {
403+
// Transient: consumed live by the client to surface the
404+
// inline 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,
@@ -509,6 +537,13 @@ interface AgentOptions {
509537
onMcpServerDiscovered: (sanitizedName: string, faviconUrl: string) => void;
510538
onMcpToolDiscovered: (modelToolName: string, rawToolName: string) => void;
511539
onMcpServerFailed: (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)) {
@@ -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
}

packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { generateAndUpdateChatNameFromMessage } from '@/ee/features/chat/actions
2929
import { isServiceError } from '@/lib/utils';
3030
import { NotConfiguredErrorBanner } from '@/features/chat/components/notConfiguredErrorBanner';
3131
import { McpServerIconContext, McpServerIconMap, McpToolNameContext, McpToolNameMap } from '../../mcpDisplayMetadataContext';
32+
import { McpReconnectContext } from '../../mcpReconnectContext';
33+
import { McpAuthRequiredData, useMcpReconnectController } from './useMcpReconnectController';
3234
import { ToolApprovalProvider } from '../../toolApprovalContext';
3335
import useCaptureEvent from '@/hooks/useCaptureEvent';
3436
import { SignInPromptBanner } from './signInPromptBanner';
@@ -153,6 +155,11 @@ export const ChatThread = ({
153155
disabledMcpServerIds: disabledMcpRef.current,
154156
}), []);
155157

158+
// The reconnect controller is created after useChat (it needs the chat's
159+
// messages and status), so transient auth-required events received in
160+
// onData are forwarded to it through a ref.
161+
const onMcpAuthRequiredRef = useRef<((data: McpAuthRequiredData) => void) | null>(null);
162+
156163
// Transport with dynamic body, resolved on every request, including auto-resends
157164
// triggered by sendAutomaticallyWhen after tool approval.
158165
// eslint-disable-next-line react-hooks/refs -- DefaultChatTransport stores the body callback and invokes it during requests, not during render.
@@ -203,6 +210,9 @@ export const ChatThread = ({
203210
});
204211
setIsFailedMcpBannerVisible(true);
205212
}
213+
if (dataPart.type === 'data-mcp-auth-required') {
214+
onMcpAuthRequiredRef.current?.(dataPart.data);
215+
}
206216
}
207217
});
208218

@@ -276,6 +286,23 @@ export const ChatThread = ({
276286
confirm: () => window.confirm("You have unsaved changes that will be lost."),
277287
});
278288

289+
const {
290+
contextValue: mcpReconnectContextValue,
291+
onAuthRequired: onMcpAuthRequired,
292+
} = useMcpReconnectController({
293+
status,
294+
messages,
295+
isTurnInProgress,
296+
addToolApprovalResponse,
297+
sendMessage,
298+
selectedSearchScopes,
299+
disabledMcpServerIds,
300+
});
301+
302+
useEffect(() => {
303+
onMcpAuthRequiredRef.current = onMcpAuthRequired;
304+
}, [onMcpAuthRequired]);
305+
279306
// When the chat is finished, refresh the page to update the chat history.
280307
const prevStatus = usePrevious(status);
281308
useEffect(() => {
@@ -395,6 +422,7 @@ export const ChatThread = ({
395422

396423
return (
397424
<ToolApprovalProvider value={addToolApprovalResponse}>
425+
<McpReconnectContext.Provider value={mcpReconnectContextValue}>
398426
<McpServerIconContext.Provider value={mcpServerIconMap}>
399427
<McpToolNameContext.Provider value={mcpToolNameMap}>
400428
<ChatPaneDropzone
@@ -547,6 +575,7 @@ export const ChatThread = ({
547575
</ChatPaneDropzone>
548576
</McpToolNameContext.Provider>
549577
</McpServerIconContext.Provider>
578+
</McpReconnectContext.Provider>
550579
</ToolApprovalProvider>
551580
);
552581
}

packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ export const StepPartRenderer = ({ part, toolTokenUsageMap }: { part: SBChatMess
548548
case 'data-mcp-server':
549549
case 'data-mcp-tool':
550550
case 'data-mcp-failed-server':
551+
case 'data-mcp-auth-required':
551552
case 'data-attachment':
552553
case 'file':
553554
case 'source-document':

packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.test.tsx

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
22
import type { DynamicToolUIPart } from 'ai';
3-
import { describe, expect, test } from 'vitest';
3+
import { afterEach, describe, expect, test, vi } from 'vitest';
44
import { McpToolNameContext } from '@/ee/features/chat/mcpDisplayMetadataContext';
5+
import { McpReconnectContext, McpReconnectContextValue, McpReconnectState } from '@/ee/features/chat/mcpReconnectContext';
56
import { getMcpToolDisplayParts, McpToolComponent } from './mcpToolComponent';
67

8+
// React Testing Library's automatic cleanup relies on vitest globals, which
9+
// this project does not enable.
10+
afterEach(cleanup);
11+
712
describe('getMcpToolDisplayParts', () => {
813
test('maps provider-safe MCP tool names back to raw tool names for display', () => {
914
expect(getMcpToolDisplayParts(
@@ -50,3 +55,145 @@ describe('McpToolComponent', () => {
5055
expect(screen.queryByText('backstage: catalog_query-catalog-entities')).toBeNull();
5156
});
5257
});
58+
59+
const createErrorPart = (toolCallId = 'tool-call-1'): DynamicToolUIPart => ({
60+
type: 'dynamic-tool',
61+
toolName: 'mcp_linear__list_issues',
62+
toolCallId,
63+
state: 'output-error',
64+
input: { query: 'open issues' },
65+
errorText: 'Authentication required: the connection to "Linear" is no longer authorized.',
66+
} as DynamicToolUIPart);
67+
68+
const createReconnectContext = (
69+
state: McpReconnectState,
70+
overrides: Partial<McpReconnectContextValue> = {},
71+
): McpReconnectContextValue => ({
72+
reconnectStates: { [state.serverId]: state },
73+
isReconnectAllowed: true,
74+
isContinueAllowed: false,
75+
reconnect: vi.fn(),
76+
continueAfterReconnect: vi.fn(),
77+
...overrides,
78+
});
79+
80+
const createReconnectState = (overrides: Partial<McpReconnectState> = {}): McpReconnectState => ({
81+
serverId: 'server-1',
82+
serverName: 'Linear',
83+
toolCallId: 'tool-call-1',
84+
status: 'authentication-required',
85+
...overrides,
86+
});
87+
88+
describe('McpToolComponent reconnect recovery UI', () => {
89+
test('shows the concise reconnect message and an enabled Reconnect action once the response settles', () => {
90+
const contextValue = createReconnectContext(createReconnectState());
91+
92+
render(
93+
<McpReconnectContext.Provider value={contextValue}>
94+
<McpToolComponent part={createErrorPart()} />
95+
</McpReconnectContext.Provider>
96+
);
97+
98+
expect(screen.getByText('Linear needs to be reconnected.')).toBeTruthy();
99+
const button = screen.getByRole('button', { name: 'Reconnect Linear' }) as HTMLButtonElement;
100+
expect(button.disabled).toBe(false);
101+
102+
fireEvent.click(button);
103+
expect(contextValue.reconnect).toHaveBeenCalledWith('server-1');
104+
});
105+
106+
test('keeps Reconnect disabled while the assistant response has not settled', () => {
107+
const contextValue = createReconnectContext(createReconnectState(), { isReconnectAllowed: false });
108+
109+
render(
110+
<McpReconnectContext.Provider value={contextValue}>
111+
<McpToolComponent part={createErrorPart()} />
112+
</McpReconnectContext.Provider>
113+
);
114+
115+
const button = screen.getByRole('button', { name: 'Reconnect Linear' }) as HTMLButtonElement;
116+
expect(button.disabled).toBe(true);
117+
});
118+
119+
test('shows a loading state while OAuth is starting', () => {
120+
const contextValue = createReconnectContext(createReconnectState({ status: 'reconnecting' }));
121+
122+
render(
123+
<McpReconnectContext.Provider value={contextValue}>
124+
<McpToolComponent part={createErrorPart()} />
125+
</McpReconnectContext.Provider>
126+
);
127+
128+
const button = screen.getByRole('button', { name: /Reconnecting/ }) as HTMLButtonElement;
129+
expect(button.disabled).toBe(true);
130+
});
131+
132+
test('shows Reconnected and Continue after a successful reconnect', () => {
133+
const contextValue = createReconnectContext(
134+
createReconnectState({ status: 'reconnected' }),
135+
{ isContinueAllowed: true },
136+
);
137+
138+
render(
139+
<McpReconnectContext.Provider value={contextValue}>
140+
<McpToolComponent part={createErrorPart()} />
141+
</McpReconnectContext.Provider>
142+
);
143+
144+
expect(screen.getByText('Reconnected')).toBeTruthy();
145+
const button = screen.getByRole('button', { name: 'Continue' });
146+
fireEvent.click(button);
147+
expect(contextValue.continueAfterReconnect).toHaveBeenCalledWith('server-1');
148+
});
149+
150+
test('hides Continue when it is not allowed (e.g. multiple failed connectors)', () => {
151+
const contextValue = createReconnectContext(
152+
createReconnectState({ status: 'reconnected' }),
153+
{ isContinueAllowed: false },
154+
);
155+
156+
render(
157+
<McpReconnectContext.Provider value={contextValue}>
158+
<McpToolComponent part={createErrorPart()} />
159+
</McpReconnectContext.Provider>
160+
);
161+
162+
expect(screen.getByText('Reconnected')).toBeTruthy();
163+
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
164+
});
165+
166+
test('keeps the technical error inside the expandable details section', () => {
167+
const contextValue = createReconnectContext(createReconnectState());
168+
169+
render(
170+
<McpReconnectContext.Provider value={contextValue}>
171+
<McpToolComponent part={createErrorPart()} />
172+
</McpReconnectContext.Provider>
173+
);
174+
175+
expect(screen.queryByText(/is no longer authorized/)).toBeNull();
176+
fireEvent.click(screen.getByText('Details'));
177+
expect(screen.getByText(/is no longer authorized/)).toBeTruthy();
178+
});
179+
180+
test('only decorates the tool result whose call failed authentication', () => {
181+
const contextValue = createReconnectContext(createReconnectState());
182+
183+
render(
184+
<McpReconnectContext.Provider value={contextValue}>
185+
<McpToolComponent part={createErrorPart('tool-call-other')} />
186+
</McpReconnectContext.Provider>
187+
);
188+
189+
expect(screen.queryByText('Linear needs to be reconnected.')).toBeNull();
190+
expect(screen.queryByRole('button', { name: 'Reconnect Linear' })).toBeNull();
191+
});
192+
193+
test('falls back to the plain error rendering without a reconnect provider', () => {
194+
render(<McpToolComponent part={createErrorPart()} />);
195+
196+
expect(screen.queryByText('Linear needs to be reconnected.')).toBeNull();
197+
expect(screen.getByText(/failed:/)).toBeTruthy();
198+
});
199+
});

0 commit comments

Comments
 (0)