Skip to content

Commit fde95a5

Browse files
committed
fix: surface MCP reconnect banner
1 parent 1ff0ff4 commit fde95a5

8 files changed

Lines changed: 209 additions & 125 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export const createMessageStream = async ({
401401
},
402402
onMcpAuthRequired: (failure) => {
403403
// Transient: consumed live by the client to surface the
404-
// inline reconnect UI, never folded into persisted parts.
404+
// connector reconnect UI, never folded into persisted parts.
405405
writer.write({
406406
type: 'data-mcp-auth-required',
407407
data: failure,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { NotConfiguredErrorBanner } from '@/features/chat/components/notConfigur
3131
import { McpServerIconContext, McpServerIconMap, McpToolNameContext, McpToolNameMap } from '../../mcpDisplayMetadataContext';
3232
import { McpReconnectContext } from '../../mcpReconnectContext';
3333
import { McpAuthRequiredData, useMcpReconnectController } from './useMcpReconnectController';
34+
import { McpReconnectBanner } from './mcpReconnectBanner';
3435
import { ToolApprovalProvider } from '../../toolApprovalContext';
3536
import useCaptureEvent from '@/hooks/useCaptureEvent';
3637
import { SignInPromptBanner } from './signInPromptBanner';
@@ -442,6 +443,7 @@ export const ChatThread = ({
442443
isVisible={isFailedMcpBannerVisible}
443444
onClose={() => setIsFailedMcpBannerVisible(false)}
444445
/>
446+
<McpReconnectBanner />
445447

446448
<div className="relative h-full w-full p-4 overflow-hidden min-h-0">
447449
<div
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
2+
import { afterEach, describe, expect, test, vi } from 'vitest';
3+
import {
4+
McpReconnectContext,
5+
McpReconnectContextValue,
6+
McpReconnectState,
7+
} from '@/ee/features/chat/mcpReconnectContext';
8+
import { McpReconnectBanner } from './mcpReconnectBanner';
9+
10+
afterEach(cleanup);
11+
12+
const createReconnectState = (overrides: Partial<McpReconnectState> = {}): McpReconnectState => ({
13+
serverId: 'server-1',
14+
serverName: 'Linear',
15+
toolCallId: 'tool-call-1',
16+
status: 'authentication-required',
17+
...overrides,
18+
});
19+
20+
const createReconnectContext = (
21+
state: McpReconnectState,
22+
overrides: Partial<McpReconnectContextValue> = {},
23+
): McpReconnectContextValue => ({
24+
reconnectStates: { [state.serverId]: state },
25+
isReconnectAllowed: true,
26+
isContinueAllowed: false,
27+
reconnect: vi.fn(),
28+
continueAfterReconnect: vi.fn(),
29+
...overrides,
30+
});
31+
32+
const renderBanner = (contextValue: McpReconnectContextValue) => render(
33+
<McpReconnectContext.Provider value={contextValue}>
34+
<McpReconnectBanner />
35+
</McpReconnectContext.Provider>
36+
);
37+
38+
describe('McpReconnectBanner', () => {
39+
test('warns about the authentication failure and exposes the reconnect action', () => {
40+
const contextValue = createReconnectContext(createReconnectState());
41+
renderBanner(contextValue);
42+
43+
expect(screen.getByRole('alert')).toBeTruthy();
44+
expect(screen.getByText('Linear authentication failed')).toBeTruthy();
45+
expect(screen.getByText('Reconnect Linear to continue using its tools.')).toBeTruthy();
46+
47+
fireEvent.click(screen.getByRole('button', { name: 'Reconnect Linear' }));
48+
expect(contextValue.reconnect).toHaveBeenCalledWith('server-1');
49+
});
50+
51+
test('keeps reconnect disabled until the assistant response settles', () => {
52+
const contextValue = createReconnectContext(
53+
createReconnectState(),
54+
{ isReconnectAllowed: false },
55+
);
56+
renderBanner(contextValue);
57+
58+
const button = screen.getByRole('button', { name: 'Reconnect Linear' }) as HTMLButtonElement;
59+
expect(button.disabled).toBe(true);
60+
});
61+
62+
test('shows progress while the reconnect flow starts', () => {
63+
const contextValue = createReconnectContext(createReconnectState({ status: 'reconnecting' }));
64+
renderBanner(contextValue);
65+
66+
const button = screen.getByRole('button', { name: /Reconnecting/ }) as HTMLButtonElement;
67+
expect(button.disabled).toBe(true);
68+
});
69+
70+
test('shows the continue action after reconnecting', () => {
71+
const contextValue = createReconnectContext(
72+
createReconnectState({ status: 'reconnected' }),
73+
{ isContinueAllowed: true },
74+
);
75+
renderBanner(contextValue);
76+
77+
expect(screen.getByRole('status')).toBeTruthy();
78+
expect(screen.getByText('Linear reconnected')).toBeTruthy();
79+
80+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
81+
expect(contextValue.continueAfterReconnect).toHaveBeenCalledWith('server-1');
82+
});
83+
84+
test('does not offer Continue when automatic continuation is unavailable', () => {
85+
const contextValue = createReconnectContext(createReconnectState({ status: 'reconnected' }));
86+
renderBanner(contextValue);
87+
88+
expect(screen.getByText('Connection restored.')).toBeTruthy();
89+
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
90+
});
91+
92+
test('does not render without a reconnect failure', () => {
93+
const contextValue: McpReconnectContextValue = {
94+
reconnectStates: {},
95+
isReconnectAllowed: true,
96+
isContinueAllowed: false,
97+
reconnect: vi.fn(),
98+
continueAfterReconnect: vi.fn(),
99+
};
100+
const { container } = renderBanner(contextValue);
101+
102+
expect(container.childElementCount).toBe(0);
103+
});
104+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client';
2+
3+
import { Button } from '@/components/ui/button';
4+
import { useMcpReconnect } from '@/ee/features/chat/mcpReconnectContext';
5+
import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
6+
7+
export const McpReconnectBanner = () => {
8+
const reconnectContext = useMcpReconnect();
9+
const reconnectStates = Object.values(reconnectContext?.reconnectStates ?? {});
10+
11+
if (!reconnectContext || reconnectStates.length === 0) {
12+
return null;
13+
}
14+
15+
return (
16+
<div>
17+
{reconnectStates.map((state) => {
18+
if (state.status === 'reconnected') {
19+
return (
20+
<div
21+
key={state.serverId}
22+
role="status"
23+
className="border-b border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20"
24+
>
25+
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
26+
<div className="flex items-start gap-2">
27+
<CheckCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-green-600 dark:text-green-400" />
28+
<div>
29+
<p className="text-sm font-medium text-green-800 dark:text-green-200">
30+
{state.serverName} reconnected
31+
</p>
32+
<p className="text-sm text-green-700 dark:text-green-300">
33+
{reconnectContext.isContinueAllowed
34+
? 'Continue to retry your request.'
35+
: 'Connection restored.'}
36+
</p>
37+
</div>
38+
</div>
39+
{reconnectContext.isContinueAllowed && (
40+
<Button
41+
size="sm"
42+
className="self-start sm:self-auto"
43+
onClick={() => reconnectContext.continueAfterReconnect(state.serverId)}
44+
>
45+
Continue
46+
</Button>
47+
)}
48+
</div>
49+
</div>
50+
);
51+
}
52+
53+
const isReconnecting = state.status === 'reconnecting';
54+
return (
55+
<div
56+
key={state.serverId}
57+
role="alert"
58+
className="border-b border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/20"
59+
>
60+
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
61+
<div className="flex items-start gap-2">
62+
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400" />
63+
<div>
64+
<p className="text-sm font-medium text-red-800 dark:text-red-200">
65+
{state.serverName} authentication failed
66+
</p>
67+
<p className="text-sm text-red-700 dark:text-red-300">
68+
Reconnect {state.serverName} to continue using its tools.
69+
</p>
70+
</div>
71+
</div>
72+
<Button
73+
size="sm"
74+
className="self-start sm:self-auto"
75+
disabled={isReconnecting || !reconnectContext.isReconnectAllowed}
76+
onClick={() => reconnectContext.reconnect(state.serverId)}
77+
>
78+
{isReconnecting ? (
79+
<>
80+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
81+
Reconnecting...
82+
</>
83+
) : (
84+
<>Reconnect {state.serverName}</>
85+
)}
86+
</Button>
87+
</div>
88+
</div>
89+
);
90+
})}
91+
</div>
92+
);
93+
};

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

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const createReconnectState = (overrides: Partial<McpReconnectState> = {}): McpRe
8686
});
8787

8888
describe('McpToolComponent reconnect recovery UI', () => {
89-
test('shows the concise reconnect message and an enabled Reconnect action once the response settles', () => {
89+
test('keeps the concise authentication error in details without duplicating the banner action', () => {
9090
const contextValue = createReconnectContext(createReconnectState());
9191

9292
render(
@@ -96,71 +96,7 @@ describe('McpToolComponent reconnect recovery UI', () => {
9696
);
9797

9898
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();
99+
expect(screen.queryByRole('button', { name: 'Reconnect Linear' })).toBeNull();
164100
});
165101

166102
test('keeps the technical error inside the expandable details section', () => {

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

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
44
import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon";
55
import { McpToolNameMap, useMcpServerIconMap, useMcpToolNameMap } from "@/ee/features/chat/mcpDisplayMetadataContext";
6-
import { getMcpReconnectStateForToolCall, McpReconnectState, useMcpReconnect } from "@/ee/features/chat/mcpReconnectContext";
6+
import { getMcpReconnectStateForToolCall, useMcpReconnect } from "@/ee/features/chat/mcpReconnectContext";
77
import { cn } from "@/lib/utils";
8-
import { Button } from "@/components/ui/button";
98
import { Separator } from "@/components/ui/separator";
109
import { DynamicToolUIPart } from "ai";
11-
import { CheckCircle, ChevronDown, Loader2, XCircle } from "lucide-react";
10+
import { CheckCircle, ChevronDown, XCircle } from "lucide-react";
1211
import { useCallback, useMemo, useState } from "react";
1312
import { JsonHighlighter, unescapeJsonStrings } from "./jsonHighlighter";
1413
import { ToolTokenBadge } from "./toolTokenBadge";
@@ -178,9 +177,6 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami
178177
</button>
179178
)}
180179
</div>
181-
{reconnectState && (
182-
<McpReconnectActions state={reconnectState} />
183-
)}
184180
{hasInput && isExpanded && (
185181
<div className="rounded-lg border border-border text-xs overflow-y-auto max-h-72">
186182
<ResultSection label={`Request (${display.displayName})`} onCopy={onCopyRequest}>
@@ -202,54 +198,6 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami
202198
);
203199
};
204200

205-
const McpReconnectActions = ({ state }: { state: McpReconnectState }) => {
206-
const reconnectContext = useMcpReconnect();
207-
if (!reconnectContext) {
208-
return null;
209-
}
210-
211-
if (state.status === 'reconnected') {
212-
return (
213-
<div className="flex items-center gap-3">
214-
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
215-
<CheckCircle className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
216-
Reconnected
217-
</span>
218-
{reconnectContext.isContinueAllowed && (
219-
<Button
220-
size="sm"
221-
onClick={() => reconnectContext.continueAfterReconnect(state.serverId)}
222-
>
223-
Continue
224-
</Button>
225-
)}
226-
</div>
227-
);
228-
}
229-
230-
const isReconnecting = state.status === 'reconnecting';
231-
return (
232-
<div className="flex items-center">
233-
<Button
234-
size="sm"
235-
variant="outline"
236-
disabled={isReconnecting || !reconnectContext.isReconnectAllowed}
237-
onClick={() => reconnectContext.reconnect(state.serverId)}
238-
>
239-
{isReconnecting ? (
240-
<>
241-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
242-
Reconnecting...
243-
</>
244-
) : (
245-
<>Reconnect {state.serverName}</>
246-
)}
247-
</Button>
248-
</div>
249-
);
250-
};
251-
252-
253201
const ResultSection = ({ label, onCopy, children }: { label: string; onCopy: () => boolean; children: React.ReactNode }) => (
254202
<div className="flex flex-col gap-1.5">
255203
<div className="sticky top-0 flex items-center justify-between bg-muted px-3 py-1.5 border-b border-border">

packages/web/src/ee/features/chat/mcpReconnectContext.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { createContext, useContext } from 'react';
55
export type McpReconnectStatus = 'authentication-required' | 'reconnecting' | 'reconnected';
66

77
// Client-only reconnect state for a connector that failed authentication in
8-
// the current assistant response. `toolCallId` is the first failed tool call,
9-
// so the inline recovery UI renders on the correct tool result. This state
10-
// intentionally does not survive an unrelated reload.
8+
// the current assistant response. `toolCallId` identifies the first failed
9+
// tool call so its technical status can be decorated while the recovery
10+
// action remains visible in the thread-level banner. This state intentionally
11+
// does not survive an unrelated reload.
1112
export interface McpReconnectState {
1213
serverId: string;
1314
serverName: string;

0 commit comments

Comments
 (0)