Skip to content

Commit 0b9a143

Browse files
Cloud UserCloud User
authored andcommitted
fix: init mcp list on chat load; stop on auth requested
1 parent 4b60ba5 commit 0b9a143

6 files changed

Lines changed: 163 additions & 13 deletions

File tree

src/app/api/chat/route.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ChatMention,
2828
ChatMetadata,
2929
} from "app-types/chat";
30+
import { isMcpAuthRequiredToolResult } from "app-types/mcp";
3031

3132
import { errorIf, safe } from "ts-safe";
3233

@@ -89,6 +90,31 @@ function isExistingAttachmentPart(part: unknown): part is ChatAttachmentPart {
8990
);
9091
}
9192

93+
function wrapToolsWithAuthAbort(
94+
tools: Record<string, Tool>,
95+
abortController: AbortController,
96+
): Record<string, Tool> {
97+
const wrapped: Record<string, Tool> = {};
98+
for (const [name, tool] of Object.entries(tools)) {
99+
if (!tool.execute) {
100+
wrapped[name] = tool;
101+
continue;
102+
}
103+
const originalExecute = tool.execute.bind(tool);
104+
wrapped[name] = {
105+
...tool,
106+
execute: async (input, options) => {
107+
const result = await originalExecute(input, options);
108+
if (isMcpAuthRequiredToolResult(result)) {
109+
abortController.abort();
110+
}
111+
return result;
112+
},
113+
};
114+
}
115+
return wrapped;
116+
}
117+
92118
export async function POST(request: Request) {
93119
try {
94120
const json = await request.json();
@@ -281,7 +307,7 @@ export async function POST(request: Request) {
281307
.orElse({});
282308
const inProgressToolParts = extractInProgressToolPart(message);
283309
if (inProgressToolParts.length) {
284-
await Promise.all(
310+
const manualToolOutputs = await Promise.all(
285311
inProgressToolParts.map(async (part) => {
286312
const output = await manualToolExecuteByLastMessage(
287313
part,
@@ -299,6 +325,9 @@ export async function POST(request: Request) {
299325
return output;
300326
}),
301327
);
328+
if (manualToolOutputs.some(isMcpAuthRequiredToolResult)) {
329+
return;
330+
}
302331
}
303332

304333
const userPreferences = thread?.userPreferences || undefined;
@@ -365,16 +394,19 @@ export async function POST(request: Request) {
365394
}
366395
logger.info(`model: ${chatModel?.provider}/${chatModel?.model}`);
367396

397+
const mcpAuthAbort = new AbortController();
398+
request.signal.addEventListener("abort", () => mcpAuthAbort.abort());
399+
368400
const result = streamText({
369401
model,
370402
system: systemPrompt,
371403
messages: convertToModelMessages(messages),
372404
experimental_transform: smoothStream({ chunking: "word" }),
373405
maxRetries: 2,
374-
tools: vercelAITooles,
406+
tools: wrapToolsWithAuthAbort(vercelAITooles, mcpAuthAbort),
375407
stopWhen: stepCountIs(10),
376408
toolChoice: "auto",
377-
abortSignal: request.signal,
409+
abortSignal: mcpAuthAbort.signal,
378410
});
379411
result.consumeStream();
380412
dataStream.merge(

src/components/chat-bot.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { getStorageManager } from "lib/browser-stroage";
4949
import { AnimatePresence, motion } from "framer-motion";
5050
import { useThreadFileUploader } from "@/hooks/use-thread-file-uploader";
5151
import { useFileDragOverlay } from "@/hooks/use-file-drag-overlay";
52+
import { useMcpList } from "@/hooks/queries/use-mcp-list";
5253

5354
type Props = {
5455
threadId: string;
@@ -71,6 +72,7 @@ const isFirstTime = firstTimeStorage.get() ?? true;
7172
firstTimeStorage.set(false);
7273

7374
export default function ChatBot({ threadId, initialMessages }: Props) {
75+
useMcpList();
7476
const containerRef = useRef<HTMLDivElement>(null);
7577
const [isAtBottom, setIsAtBottom] = useState(true);
7678
const { uploadFiles } = useThreadFileUploader(threadId);

src/components/mcp-auth-dialog.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useCallback, useState, useEffect } from "react";
3+
import { useCallback, useState, useEffect, useRef } from "react";
44
import { mutate } from "swr";
55
import { toast } from "sonner";
66
import { Loader } from "lucide-react";
@@ -18,17 +18,40 @@ import { Button } from "ui/button";
1818
import { redriectMcpOauth } from "lib/ai/mcp/oauth-redirect";
1919
import type { MCPServerInfo } from "app-types/mcp";
2020

21+
const activeAuthDialogs = new Set<string>();
22+
2123
interface MCPAuthDialogProps {
2224
server: MCPServerInfo | undefined;
2325
serverName: string;
26+
onAuthorized?: () => Promise<void> | void;
2427
}
2528

26-
export function MCPAuthDialog({ server, serverName }: MCPAuthDialogProps) {
29+
export function MCPAuthDialog({
30+
server,
31+
serverName,
32+
onAuthorized,
33+
}: MCPAuthDialogProps) {
2734
const t = useTranslations();
2835
const [loading, setLoading] = useState(false);
2936
const [dismissed, setDismissed] = useState(false);
37+
const claimedRef = useRef(false);
3038

3139
const needsAuth = server?.status === "authorizing";
40+
const serverId = server?.id;
41+
42+
const isDuplicate = needsAuth && serverId && activeAuthDialogs.has(serverId);
43+
44+
useEffect(() => {
45+
if (!needsAuth || !serverId || isDuplicate) {
46+
return;
47+
}
48+
activeAuthDialogs.add(serverId);
49+
claimedRef.current = true;
50+
return () => {
51+
activeAuthDialogs.delete(serverId);
52+
claimedRef.current = false;
53+
};
54+
}, [needsAuth, serverId, isDuplicate]);
3255

3356
useEffect(() => {
3457
if (!needsAuth) {
@@ -41,11 +64,12 @@ export function MCPAuthDialog({ server, serverName }: MCPAuthDialogProps) {
4164
setLoading(true);
4265
redriectMcpOauth(server.id)
4366
.then(() => mutate("/api/mcp/list"))
67+
.then(() => onAuthorized?.())
4468
.catch((err) => toast.error(err?.message || t("MCP.authorizationFailed")))
4569
.finally(() => setLoading(false));
46-
}, [server, t]);
70+
}, [server, t, onAuthorized]);
4771

48-
if (!server || !needsAuth || dismissed) return null;
72+
if (!server || !needsAuth || dismissed || isDuplicate) return null;
4973

5074
return (
5175
<Dialog open onOpenChange={(open) => !open && setDismissed(true)}>

src/components/message-parts.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,40 @@ interface ToolMessagePartProps {
118118

119119
const MAX_TEXT_LENGTH = 600;
120120

121+
async function replayMessageAfterMcpAuth({
122+
replayMessage,
123+
blockedMessageId,
124+
threadId,
125+
setMessages,
126+
sendMessage,
127+
}: {
128+
replayMessage?: UIMessage;
129+
blockedMessageId: string;
130+
threadId?: string;
131+
setMessages?: UseChatHelpers<UIMessage>["setMessages"];
132+
sendMessage?: UseChatHelpers<UIMessage>["sendMessage"];
133+
}) {
134+
if (!replayMessage || !setMessages || !sendMessage) {
135+
return;
136+
}
137+
138+
if (threadId) {
139+
await deleteMessagesByChatIdAfterTimestampAction(blockedMessageId);
140+
}
141+
142+
setMessages((messages) => {
143+
const replayIndex = messages.findIndex((m) => m.id === replayMessage.id);
144+
145+
if (replayIndex === -1) {
146+
return messages;
147+
}
148+
149+
return messages.slice(0, replayIndex);
150+
});
151+
152+
sendMessage(replayMessage);
153+
}
154+
121155
export const UserMessagePart = memo(
122156
function UserMessagePart({
123157
part,
@@ -762,7 +796,10 @@ export const ToolMessagePart = memo(
762796
addToolResult,
763797
isError,
764798
messageId,
799+
prevMessage,
800+
threadId,
765801
setMessages,
802+
sendMessage,
766803
isManualToolInvocation,
767804
}: ToolMessagePartProps) => {
768805
const t = useTranslations("");
@@ -977,7 +1014,21 @@ export const ToolMessagePart = memo(
9771014

9781015
return (
9791016
<div className="group w-full">
980-
<MCPAuthDialog server={mcpServer} serverName={mcpServerName} />
1017+
{isLast && (
1018+
<MCPAuthDialog
1019+
server={mcpServer}
1020+
serverName={mcpServerName}
1021+
onAuthorized={() =>
1022+
replayMessageAfterMcpAuth({
1023+
replayMessage: prevMessage,
1024+
blockedMessageId: messageId,
1025+
threadId,
1026+
setMessages,
1027+
sendMessage,
1028+
})
1029+
}
1030+
/>
1031+
)}
9811032

9821033
{CustomToolComponent ? (
9831034
CustomToolComponent

src/lib/ai/mcp/create-mcp-client.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,30 @@ export class MCPClient {
388388
async callTool(toolName: string, input?: unknown) {
389389
const id = generateUUID();
390390
this.inProgressToolCallIds.push(id);
391+
const authRequiredResult = () => ({
392+
isError: true,
393+
content: [
394+
{
395+
type: "text",
396+
text: "OAuth authorization required",
397+
},
398+
],
399+
_mcpAuthRequired: true,
400+
_mcpServerId: this.id,
401+
});
391402
const execute = async () => {
392-
const client = await this.connect();
393-
return await client?.callTool({
394-
name: toolName,
395-
arguments: input as Record<string, unknown>,
396-
});
403+
try {
404+
const client = await this.connect();
405+
return await client?.callTool({
406+
name: toolName,
407+
arguments: input as Record<string, unknown>,
408+
});
409+
} catch (err) {
410+
if (this.status === "authorizing") {
411+
return authRequiredResult();
412+
}
413+
throw err;
414+
}
397415
};
398416
return safe(() => this.logger.info("tool call", toolName))
399417
.ifOk(() => this.scheduleAutoDisconnect()) // disconnect if autoDisconnectSeconds is set
@@ -419,6 +437,9 @@ export class MCPClient {
419437
// Expected - triggers OAuth flow
420438
}
421439
}
440+
if (this.status === "authorizing") {
441+
return authRequiredResult();
442+
}
422443
throw err;
423444
})
424445
.ifOk((v) => {

src/types/mcp.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,30 @@ export const CallToolResultSchema = z.object({
248248
content: z.array(ContentUnion).default([]),
249249
structuredContent: z.object({}).passthrough().optional(),
250250
isError: z.boolean().optional(),
251+
_mcpAuthRequired: z.boolean().optional(),
252+
_mcpServerId: z.string().optional(),
251253
});
252254

253255
export type CallToolResult = z.infer<typeof CallToolResultSchema>;
254256

257+
export type McpAuthRequiredToolResult = CallToolResult & {
258+
_mcpAuthRequired: true;
259+
_mcpServerId: string;
260+
};
261+
262+
export function isMcpAuthRequiredToolResult(
263+
value: unknown,
264+
): value is McpAuthRequiredToolResult {
265+
return (
266+
typeof value === "object" &&
267+
value !== null &&
268+
"_mcpAuthRequired" in value &&
269+
"_mcpServerId" in value &&
270+
(value as { _mcpAuthRequired?: unknown })._mcpAuthRequired === true &&
271+
typeof (value as { _mcpServerId?: unknown })._mcpServerId === "string"
272+
);
273+
}
274+
255275
export type McpOAuthSession = {
256276
id: string;
257277
mcpServerId: string;

0 commit comments

Comments
 (0)