diff --git a/messages/en.json b/messages/en.json index 1ac73a758..62de2ee5f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -793,6 +793,8 @@ "public": "Featured", "publicDescription": "Featured for all users to use", "featured": "Featured", - "featuredDescription": "Featured for all users to use" + "featuredDescription": "Featured for all users to use", + "perUserAuthentication": "Per-User Authentication", + "perUserAuthenticationDescription": "Each user must authenticate separately with their own credentials" } } diff --git a/src/app/(chat)/mcp/modify/[id]/page.tsx b/src/app/(chat)/mcp/modify/[id]/page.tsx index d96e35f53..9906c9a82 100644 --- a/src/app/(chat)/mcp/modify/[id]/page.tsx +++ b/src/app/(chat)/mcp/modify/[id]/page.tsx @@ -42,6 +42,7 @@ export default async function Page({ initialConfig={mcpClient.config} name={mcpClient.name} id={mcpClient.id} + perUserAuth={mcpClient.perUserAuth} /> ) : ( MCP client not found diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9f6e4e21c..657b8ce03 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -204,8 +204,8 @@ export async function POST(request: Request) { const stream = createUIMessageStream({ execute: async ({ writer: dataStream }) => { - const mcpClients = await mcpClientsManager.getClients(); - const mcpTools = await mcpClientsManager.tools(); + const mcpClients = await mcpClientsManager.getClients(session.user.id); + const mcpTools = await mcpClientsManager.tools(session.user.id); logger.info( `mcp-server count: ${mcpClients.length}, mcp-tools count :${Object.keys(mcpTools).length}`, ); @@ -213,6 +213,7 @@ export async function POST(request: Request) { .map(errorIf(() => !isToolCallAllowed && "Not allowed")) .map(() => loadMcpTools({ + userId: session.user.id, mentions, allowedMcpServers, }), @@ -245,6 +246,7 @@ export async function POST(request: Request) { const output = await manualToolExecuteByLastMessage( part, { ...MCP_TOOLS, ...WORKFLOW_TOOLS, ...APP_DEFAULT_TOOLS }, + session.user.id, request.signal, ); part.output = output; diff --git a/src/app/api/chat/shared.chat.ts b/src/app/api/chat/shared.chat.ts index e794efb9f..76b150568 100644 --- a/src/app/api/chat/shared.chat.ts +++ b/src/app/api/chat/shared.chat.ts @@ -115,6 +115,7 @@ export function mergeSystemPrompt( export function manualToolExecuteByLastMessage( part: ToolUIPart, tools: Record, + userId?: string, abortSignal?: AbortSignal, ) { const { input } = part; @@ -141,6 +142,7 @@ export function manualToolExecuteByLastMessage( tool._mcpServerId, tool._originToolName, input, + userId, ); } return tool.execute!(input, { @@ -394,10 +396,11 @@ export const workflowToVercelAITools = ( }; export const loadMcpTools = (opt?: { + userId?: string; mentions?: ChatMention[]; allowedMcpServers?: Record; }) => - safe(() => mcpClientsManager.tools()) + safe(() => mcpClientsManager.tools(opt?.userId)) .map((tools) => { if (opt?.mentions?.length) { return filterMCPToolsByMentions(tools, opt.mentions); diff --git a/src/app/api/mcp/actions.ts b/src/app/api/mcp/actions.ts index b5b120ca4..e934cd009 100644 --- a/src/app/api/mcp/actions.ts +++ b/src/app/api/mcp/actions.ts @@ -22,29 +22,61 @@ export async function selectMcpClientsAction() { const accessibleServers = await mcpRepository.selectAllForUser( currentUser.id, ); - const accessibleIds = new Set(accessibleServers.map((s) => s.id)); + + // Warm up clients for the current user + await Promise.allSettled( + accessibleServers.map((server) => + mcpClientsManager.getClient(server.id, currentUser.id), + ), + ); // Get all active clients and filter to only accessible ones - const list = await mcpClientsManager.getClients(); - return list - .filter(({ id }) => accessibleIds.has(id)) - .map(({ client, id }) => { - const server = accessibleServers.find((s) => s.id === id); - return { - ...client.getInfo(), - id, - userId: server?.userId, - visibility: server?.visibility, - isOwner: server?.userId === currentUser.id, - canManage: server - ? server.userId === currentUser.id || currentUser.role === "admin" - : false, - }; - }); + const list = await mcpClientsManager.getClients(currentUser.id); + const activeClientsMap = new Map(list.map((c) => [c.id, c.client])); + + // Check authorization status for per-user auth servers + const authStatuses = await Promise.all( + accessibleServers.map(async (server) => { + if (!server.perUserAuth) return { id: server.id, isAuthorized: true }; + const session = await mcpOAuthRepository.getAuthenticatedSession( + server.id, + currentUser.id, + ); + return { id: server.id, isAuthorized: !!session?.tokens }; + }), + ); + const authStatusMap = new Map( + authStatuses.map((s) => [s.id, s.isAuthorized]), + ); + + return accessibleServers.map((server) => { + const client = activeClientsMap.get(server.id); + const info = client?.getInfo(); + + return { + id: server.id, + name: server.name, + config: server.userId === currentUser.id ? server.config : undefined, + status: info?.status ?? ("disconnected" as const), + enabled: info?.enabled ?? true, + userId: server.userId, + visibility: server.visibility, + perUserAuth: server.perUserAuth ?? false, + isAuthorized: authStatusMap.get(server.id), + toolInfo: + info?.toolInfo && info.toolInfo.length > 0 + ? info.toolInfo + : (server.toolInfo ?? []), + isOwner: server.userId === currentUser.id, + canManage: + server.userId === currentUser.id || currentUser.role === "admin", + }; + }); } export async function selectMcpClientAction(id: string) { - const client = await mcpClientsManager.getClient(id); + const currentUser = await getCurrentUser(); + const client = await mcpClientsManager.getClient(id, currentUser?.id); if (!client) { throw new Error("Client not found"); } @@ -105,6 +137,7 @@ export async function saveMcpClientAction( ...server, userId: currentUser.id, visibility: server.visibility || "private", + toolInfo: server.toolInfo ?? undefined, }; return mcpClientsManager.persistClient(serverWithUser); @@ -134,12 +167,14 @@ export async function removeMcpClientAction(id: string) { } export async function refreshMcpClientAction(id: string) { - await mcpClientsManager.refreshClient(id); + const currentUser = await getCurrentUser(); + await mcpClientsManager.refreshClient(id, currentUser?.id); } export async function authorizeMcpClientAction(id: string) { + const currentUser = await getCurrentUser(); await refreshMcpClientAction(id); - const client = await mcpClientsManager.getClient(id); + const client = await mcpClientsManager.getClient(id, currentUser?.id); if (client?.client.status != "authorizing") { throw new Error("Not Authorizing"); } @@ -147,10 +182,14 @@ export async function authorizeMcpClientAction(id: string) { } export async function checkTokenMcpClientAction(id: string) { - const session = await mcpOAuthRepository.getAuthenticatedSession(id); + const currentUser = await getCurrentUser(); + const session = await mcpOAuthRepository.getAuthenticatedSession( + id, + currentUser?.id, + ); // for wait connect to mcp server - await mcpClientsManager.getClient(id).catch(() => null); + await mcpClientsManager.getClient(id, currentUser?.id).catch(() => null); return !!session?.tokens; } @@ -160,7 +199,8 @@ export async function callMcpToolAction( toolName: string, input: unknown, ) { - return mcpClientsManager.toolCall(id, toolName, input); + const currentUser = await getCurrentUser(); + return mcpClientsManager.toolCall(id, toolName, input, currentUser?.id); } export async function callMcpToolByServerNameAction( @@ -168,7 +208,13 @@ export async function callMcpToolByServerNameAction( toolName: string, input: unknown, ) { - return mcpClientsManager.toolCallByServerName(serverName, toolName, input); + const currentUser = await getCurrentUser(); + return mcpClientsManager.toolCallByServerName( + serverName, + toolName, + input, + currentUser?.id, + ); } export async function shareMcpServerAction( @@ -186,3 +232,33 @@ export async function shareMcpServerAction( return { success: true }; } + +export async function updatePerUserAuthAction( + id: string, + perUserAuth: boolean, +) { + // Get the MCP server to check ownership + const mcpServer = await mcpRepository.selectById(id); + if (!mcpServer) { + throw new Error("MCP server not found"); + } + + // Check if user has permission to manage this specific MCP server + const canManage = await canManageMCPServer( + mcpServer.userId, + mcpServer.visibility, + ); + if (!canManage) { + throw new Error( + "You don't have permission to update this MCP connection settings", + ); + } + + // Update the perUserAuth of the MCP server + await mcpRepository.updatePerUserAuth(id, perUserAuth); + + // Refresh the client to apply changes + await mcpClientsManager.refreshClient(id); + + return { success: true }; +} diff --git a/src/app/api/mcp/list/route.ts b/src/app/api/mcp/list/route.ts index b02d60173..13be7eb75 100644 --- a/src/app/api/mcp/list/route.ts +++ b/src/app/api/mcp/list/route.ts @@ -1,6 +1,6 @@ import { MCPServerInfo } from "app-types/mcp"; import { mcpClientsManager } from "lib/ai/mcp/mcp-manager"; -import { mcpRepository } from "lib/db/repository"; +import { mcpOAuthRepository, mcpRepository } from "lib/db/repository"; import { getCurrentUser } from "lib/auth/permissions"; export async function GET() { @@ -10,37 +10,60 @@ export async function GET() { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const [servers, memoryClients] = await Promise.all([ - mcpRepository.selectAllForUser(currentUser.id), - mcpClientsManager.getClients(), - ]); + const servers = await mcpRepository.selectAllForUser(currentUser.id); + const memoryClientsBefore = await mcpClientsManager.getClients( + currentUser.id, + ); const memoryMap = new Map( - memoryClients.map(({ id, client }) => [id, client] as const), + memoryClientsBefore.map(({ id, client }) => [id, client] as const), ); const addTargets = servers.filter((server) => !memoryMap.has(server.id)); const serverIds = new Set(servers.map((s) => s.id)); - const removeTargets = memoryClients.filter(({ id }) => !serverIds.has(id)); + const removeTargets = memoryClientsBefore.filter( + ({ id }) => !serverIds.has(id), + ); if (addTargets.length > 0) { - // no need to wait for this - Promise.allSettled( - addTargets.map((server) => mcpClientsManager.refreshClient(server.id)), + await Promise.allSettled( + addTargets.map((server) => + mcpClientsManager.refreshClient(server.id, currentUser.id), + ), ); } if (removeTargets.length > 0) { - // no need to wait for this - Promise.allSettled( + await Promise.allSettled( removeTargets.map((client) => - mcpClientsManager.disconnectClient(client.id), + mcpClientsManager.disconnectClient(client.clientId), ), ); } + // Fetch again to get updated statuses + const memoryClients = await mcpClientsManager.getClients(currentUser.id); + const updatedMemoryMap = new Map( + memoryClients.map(({ id, client }) => [id, client] as const), + ); + + // Check authorization status for per-user auth servers + const authStatuses = await Promise.all( + servers.map(async (server) => { + if (!server.perUserAuth) return { id: server.id, isAuthorized: true }; + const session = await mcpOAuthRepository.getAuthenticatedSession( + server.id, + currentUser.id, + ); + return { id: server.id, isAuthorized: !!session?.tokens }; + }), + ); + const authStatusMap = new Map( + authStatuses.map((s) => [s.id, s.isAuthorized]), + ); + const result = servers.map((server) => { - const mem = memoryMap.get(server.id); + const mem = updatedMemoryMap.get(server.id); const info = mem?.getInfo(); const isOwner = server.userId === currentUser.id; const mcpInfo: MCPServerInfo = { @@ -48,9 +71,13 @@ export async function GET() { // Hide config from non-owners to prevent credential exposure config: isOwner ? server.config : undefined, enabled: info?.enabled ?? true, - status: info?.status ?? "connected", + status: info?.status ?? "disconnected", error: info?.error, - toolInfo: info?.toolInfo ?? [], + isAuthorized: authStatusMap.get(server.id), + toolInfo: + info?.toolInfo && info.toolInfo.length > 0 + ? info.toolInfo + : (server.toolInfo ?? []), }; return mcpInfo; }); diff --git a/src/app/api/mcp/oauth/callback/route.ts b/src/app/api/mcp/oauth/callback/route.ts index 733467875..5d6dcc547 100644 --- a/src/app/api/mcp/oauth/callback/route.ts +++ b/src/app/api/mcp/oauth/callback/route.ts @@ -141,11 +141,17 @@ export async function GET(request: NextRequest) { }); } - const client = await mcpClientsManager.getClient(session.mcpServerId); + const client = await mcpClientsManager.getClient( + session.mcpServerId, + session.userId || undefined, + ); try { await client?.client.finishAuth(callbackData.code, callbackData.state); - await mcpClientsManager.refreshClient(session.mcpServerId); + await mcpClientsManager.refreshClient( + session.mcpServerId, + session.userId || undefined, + ); return createOAuthResponsePage({ type: "success", diff --git a/src/components/agent/edit-agent.tsx b/src/components/agent/edit-agent.tsx index 259805523..e87797730 100644 --- a/src/components/agent/edit-agent.tsx +++ b/src/components/agent/edit-agent.tsx @@ -123,7 +123,7 @@ export default function EditAgent({ }); (mcpList as (MCPServerInfo & { id: string })[])?.forEach((mcp) => { - mcp.toolInfo.forEach((tool) => { + mcp.toolInfo?.forEach((tool) => { if (toolNames.includes(tool.name)) { allMentions.push({ type: "mcpTool", diff --git a/src/components/chat-bot-voice.tsx b/src/components/chat-bot-voice.tsx index de692606b..926f4aa67 100644 --- a/src/components/chat-bot-voice.tsx +++ b/src/components/chat-bot-voice.tsx @@ -105,7 +105,7 @@ export function ChatBotVoice() { .flatMap((v) => { const tools = allowedMcpServers[v.id].tools; return tools.map((tool) => { - const toolInfo = v.toolInfo.find((t) => t.name === tool); + const toolInfo = v.toolInfo?.find((t) => t.name === tool); const mention: ChatMention = { type: "mcpTool", serverName: v.name, diff --git a/src/components/chat-mention-input.tsx b/src/components/chat-mention-input.tsx index e11382c31..a0e631591 100644 --- a/src/components/chat-mention-input.tsx +++ b/src/components/chat-mention-input.tsx @@ -8,7 +8,12 @@ import React, { useEffect, } from "react"; -import { CheckIcon, HammerIcon, SearchIcon } from "lucide-react"; +import { + CheckIcon, + HammerIcon, + SearchIcon, + ShieldAlertIcon, +} from "lucide-react"; import { MCPIcon } from "ui/mcp-icon"; import { ChatMention } from "app-types/chat"; @@ -170,16 +175,14 @@ export function ChatMentionInputSuggestion({ const mcpMentions = useMemo(() => { if (disabledType?.includes("mcp")) return []; - const filtered = mcpList - ?.filter((mcp) => mcp.toolInfo?.length) - .filter((mcp) => { - if (!searchValue) return true; - const search = searchValue.toLowerCase(); - return ( - mcp.name.toLowerCase().includes(search) || - mcp.toolInfo?.some((tool) => tool.name.toLowerCase().includes(search)) - ); - }); + const filtered = mcpList?.filter((mcp) => { + if (!searchValue) return true; + const search = searchValue.toLowerCase(); + return ( + mcp.name.toLowerCase().includes(search) || + mcp.toolInfo?.some((tool) => tool.name.toLowerCase().includes(search)) + ); + }); return ( filtered?.flatMap((mcp) => { @@ -198,6 +201,12 @@ export function ChatMentionInputSuggestion({ !searchValue || mcp.name.toLowerCase().includes(searchValue.toLowerCase()) ) { + const needsAuth = + mcp.status === "authorizing" || + (mcp.perUserAuth && + !mcp.isAuthorized && + mcp.status === "disconnected"); + items.push({ id: `${mcp.id}-mcp`, type: "mcp", @@ -211,14 +220,25 @@ export function ChatMentionInputSuggestion({ suffix: selectedIds?.includes(mcpId) ? ( ) : ( - - {mcp.toolInfo?.length} tools - +
+ {needsAuth && ( + + )} + + {mcp.toolInfo?.length} tools + +
), }); } // Add tool items + const needsAuth = + mcp.status === "authorizing" || + (mcp.perUserAuth && + !mcp.isAuthorized && + mcp.status === "disconnected"); + const toolItems = mcp.toolInfo ?.filter( @@ -244,9 +264,11 @@ export function ChatMentionInputSuggestion({ id: toolId, }), icon: , - suffix: selectedIds?.includes(toolId) && ( + suffix: selectedIds?.includes(toolId) ? ( - ), + ) : needsAuth ? ( + + ) : null, }; }) || []; diff --git a/src/components/mcp-card.tsx b/src/components/mcp-card.tsx index fb0abc123..6ca7a64c4 100644 --- a/src/components/mcp-card.tsx +++ b/src/components/mcp-card.tsx @@ -53,6 +53,8 @@ export const MCPCard = memo(function MCPCard({ user, userName, userAvatar, + perUserAuth, + isAuthorized, }: MCPServerInfo & { user: BasicUser }) { const [isProcessing, setIsProcessing] = useState(false); const [visibilityChangeLoading, setVisibilityChangeLoading] = useState(false); @@ -69,8 +71,10 @@ export const MCPCard = memo(function MCPCard({ return isProcessing || status === "loading"; }, [isProcessing, status]); - const needsAuthorization = status === "authorizing"; - const isDisabled = isLoading || needsAuthorization; + const needsAuthorization = + status === "authorizing" || + (perUserAuth && !isAuthorized && status === "disconnected"); + const isDisabled = isLoading || status === "authorizing"; // Check permissions (kept for potential future use) @@ -184,6 +188,7 @@ export const MCPCard = memo(function MCPCard({ visibility, enabled, userId, + perUserAuth, }, }) } @@ -329,7 +334,7 @@ export const MCPCard = memo(function MCPCard({
- {toolInfo.length > 0 ? ( + {toolInfo && toolInfo.length > 0 ? ( ) : (
diff --git a/src/components/mcp-customization-popup.tsx b/src/components/mcp-customization-popup.tsx index e055a85b0..575631ce4 100644 --- a/src/components/mcp-customization-popup.tsx +++ b/src/components/mcp-customization-popup.tsx @@ -40,6 +40,10 @@ import { ExamplePlaceholder } from "ui/example-placeholder"; import { Input } from "ui/input"; import { appStore } from "@/app/store"; import { useShallow } from "zustand/shallow"; +import { Switch } from "ui/switch"; +import { Label } from "ui/label"; +import { updatePerUserAuthAction } from "@/app/api/mcp/actions"; +import { useSWRConfig } from "swr"; export function McpCustomizationPopup() { const [mcpCustomizationPopup, appStoreMutate] = appStore( @@ -67,20 +71,34 @@ export function McpCustomizationPopup() { } export function McpServerCustomizationContent({ - mcpServerInfo: { id, name, toolInfo, error }, + mcpServerInfo: { id, name, toolInfo, error, perUserAuth }, title, }: { mcpServerInfo: MCPServerInfo & { id: string }; title?: ReactNode; }) { const t = useTranslations(); + const { mutate } = useSWRConfig(); const [prompt, setPrompt] = useState(""); const [search, setSearch] = useState(""); const [isProcessing, setIsProcessing] = useState(false); + const [isPerUserAuthLoading, setIsPerUserAuthLoading] = useState(false); const [selectedTool, setSelectedTool] = useState(null); + const handlePerUserAuthChange = async (checked: boolean) => { + setIsPerUserAuthLoading(true); + try { + await updatePerUserAuthAction(id, checked); + mutate("/api/mcp/list"); + } catch (e) { + handleErrorWithToast(e as any); + } finally { + setIsPerUserAuthLoading(false); + } + }; + const handleSave = () => { setIsProcessing(true); safe(() => @@ -150,7 +168,7 @@ export function McpServerCustomizationContent({ const mcpToolCustomizationsMap = new Map( mcpToolCustomizations?.map((tool) => [tool.toolName, tool]), ); - return toolInfo + return (toolInfo || []) .filter((tool) => tool.name.includes(search)) .map((tool) => { return { @@ -194,6 +212,38 @@ export function McpServerCustomizationContent({ {/* */} +
+
+
+
+ +
+
+ +

+ {t("MCP.perUserAuthenticationDescription")} +

+
+
+
+ {isPerUserAuthLoading && ( + + )} + +
+
+
+
diff --git a/src/components/mcp-editor.tsx b/src/components/mcp-editor.tsx index 5115ae634..7d54a38d8 100644 --- a/src/components/mcp-editor.tsx +++ b/src/components/mcp-editor.tsx @@ -9,6 +9,7 @@ import { Input } from "./ui/input"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; +import { Switch } from "./ui/switch"; import JsonView from "./ui/json-view"; import { toast } from "sonner"; import { safe } from "ts-safe"; @@ -31,6 +32,7 @@ interface MCPEditorProps { initialConfig?: MCPServerConfig; name?: string; id?: string; + perUserAuth?: boolean; } const STDIO_ARGS_ENV_PLACEHOLDER = `/** STDIO Example */ @@ -54,6 +56,7 @@ export default function MCPEditor({ initialConfig, name: initialName, id, + perUserAuth: initialPerUserAuth = false, }: MCPEditorProps) { const t = useTranslations(); const shouldInsert = useMemo(() => isNull(id), [id]); @@ -66,6 +69,7 @@ export default function MCPEditor({ // State for form fields const [name, setName] = useState(initialName ?? ""); + const [perUserAuth, setPerUserAuth] = useState(initialPerUserAuth); const router = useRouter(); const [config, setConfig] = useState( initialConfig as MCPServerConfig, @@ -146,6 +150,7 @@ export default function MCPEditor({ name, config, id, + perUserAuth, }), }), ) @@ -195,6 +200,23 @@ export default function MCPEditor({ /> {nameError &&

{nameError}

}
+ + {/* Per-User Auth field */} +
+
+ +

+ When enabled, each user will need to provide their own credentials + for this MCP server. +

+
+ +
+
diff --git a/src/components/message-parts.tsx b/src/components/message-parts.tsx index 366d989c8..62d2f3626 100644 --- a/src/components/message-parts.tsx +++ b/src/components/message-parts.tsx @@ -43,6 +43,9 @@ import { ChatMetadata, ChatModel, ManualToolConfirmTag } from "app-types/chat"; import { useTranslations } from "next-intl"; import { extractMCPToolId } from "lib/ai/mcp/mcp-tool-id"; import { Separator } from "ui/separator"; +import { Alert, AlertDescription, AlertTitle } from "ui/alert"; +import { appStore } from "@/app/store"; +import { redriectMcpOauth } from "lib/ai/mcp/oauth-redirect"; import { TextShimmer } from "ui/text-shimmer"; import equal from "lib/equal"; @@ -62,7 +65,6 @@ import { WorkflowInvocation } from "./tool-invocation/workflow-invocation"; import dynamic from "next/dynamic"; import { notify } from "lib/notify"; import { ModelProviderIcon } from "ui/model-provider-icon"; -import { appStore } from "@/app/store"; import { BACKGROUND_COLORS, EMOJI_DATA } from "lib/const"; type MessagePart = UIMessage["parts"][number]; @@ -162,6 +164,43 @@ export const UserMessagePart = memo( } }, [status]); + const mcpList = appStore((state) => state.mcpList); + + const mentionAuthRequired = useMemo(() => { + if (!isLast) return null; + const mcpMentions = part.text.match(/mcp\("([^"]+)"\)/g); + const toolMentions = part.text.match(/tool\("([^"]+)"\)/g); + + if (!mcpMentions && !toolMentions) return null; + + const serverNames = new Set(); + mcpMentions?.forEach((m) => { + const name = m.match(/mcp\("([^"]+)"\)/)?.[1]; + if (name) serverNames.add(name); + }); + toolMentions?.forEach((m) => { + const toolName = m.match(/tool\("([^"]+)"\)/)?.[1]; + if (toolName) { + const { serverName } = extractMCPToolId(toolName); + if (serverName) serverNames.add(serverName); + } + }); + + for (const serverName of serverNames) { + const server = mcpList.find((s) => s.name === serverName); + if ( + server && + (server.status === "authorizing" || + (server.perUserAuth && + !server.isAuthorized && + server.status === "disconnected")) + ) { + return server; + } + } + return null; + }, [part.text, mcpList, isLast]); + if (mode === "edit" && setMessages && sendMessage) { return (
@@ -212,6 +251,26 @@ export const UserMessagePart = memo( )}
+ {mentionAuthRequired && ( +
+ { + await redriectMcpOauth(mentionAuthRequired.id); + }} + role="button" + tabIndex={0} + > + + + Authorization Required: {mentionAuthRequired.name} + + + Click here to authorize this MCP server. + + +
+ )} {isLast && (
@@ -1046,6 +1105,24 @@ export const ToolMessagePart = memo( + ) : (result as any)?._mcpAuthRequired ? ( +
+ { + await redriectMcpOauth((result as any)._mcpServerId); + }} + role="button" + tabIndex={0} + > + + Authorization Required + + Click here to authorize this MCP server and access its + tools. + + +
) : (
0, - tools: server.toolInfo.map((tool) => ({ - name: tool.name, - checked: allowedTools.includes(tool.name), - description: tool.description, - })), + tools: + server.toolInfo?.map((tool) => ({ + name: tool.name, + checked: allowedTools.includes(tool.name), + description: tool.description, + })) ?? [], error: server.error, status: server.status, + perUserAuth: server.perUserAuth, + isAuthorized: server.isAuthorized, }; }); }, [mcpServerList, allowedMcpServers]); @@ -653,7 +656,10 @@ function McpServerSelector() { className="flex items-center gap-2 font-semibold cursor-pointer" icon={
- {server.status === "authorizing" ? ( + {server.status === "authorizing" || + (server.perUserAuth && + !server.isAuthorized && + server.status === "disconnected") ? (
@@ -696,7 +702,12 @@ function McpServerSelector() { { @@ -818,8 +829,12 @@ function McpServerToolSelector({
{filteredTools.length === 0 ? ( -
- {t("noResults")} +
+ {isAuthorizing ? ( +

Authorize to see available tools.

+ ) : ( +

{t("noResults")}

+ )}
) : ( filteredTools.map((tool) => ( diff --git a/src/components/workflow/node-config/tool-node-config.tsx b/src/components/workflow/node-config/tool-node-config.tsx index b083b928e..bd8a3baee 100644 --- a/src/components/workflow/node-config/tool-node-config.tsx +++ b/src/components/workflow/node-config/tool-node-config.tsx @@ -51,7 +51,7 @@ export const ToolNodeDataConfig = memo(function ({ const toolList = useMemo(() => { const mcpTools: WorkflowToolKey[] = (mcpList || []).flatMap((mcp) => { - return mcp.toolInfo.map((tool) => { + return (mcp.toolInfo || []).map((tool) => { return { type: "mcp-tool", serverId: mcp.id, diff --git a/src/lib/ai/mcp/create-mcp-client.ts b/src/lib/ai/mcp/create-mcp-client.ts index c537efbba..692f597a0 100644 --- a/src/lib/ai/mcp/create-mcp-client.ts +++ b/src/lib/ai/mcp/create-mcp-client.ts @@ -31,6 +31,9 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; type ClientOptions = { autoDisconnectSeconds?: number; + userId?: string; + perUserAuth?: boolean; + onToolInfoUpdate?: (toolInfo: MCPToolInfo[]) => void; }; const CONNET_TIMEOUT = IS_VERCEL_ENV ? 30000 : 120000; @@ -68,6 +71,9 @@ export class MCPClient { `[${this.id.slice(0, 4)}] MCP Client ${this.name}: `, ), }); + if (this.options.perUserAuth) { + this.needOauthProvider = true; + } } get status() { @@ -119,6 +125,7 @@ export class MCPClient { toolInfo: this.toolInfo, visibility: "private" as const, enabled: true, + perUserAuth: this.options.perUserAuth ?? false, userId: "", // This will be filled by the manager }; } @@ -135,6 +142,7 @@ export class MCPClient { this.oauthProvider = new PgOAuthClientProvider({ name: this.name, mcpServerId: this.id, + userId: this.options.userId, serverUrl: this.serverConfig.url, state: oauthState, _clientMetadata: { @@ -349,6 +357,7 @@ export class MCPClient { inputSchema: tool.inputSchema, }) as MCPToolInfo, ); + this.options.onToolInfoUpdate?.(this.toolInfo); } } @@ -356,14 +365,28 @@ export class MCPClient { const id = generateUUID(); this.inProgressToolCallIds.push(id); const execute = async () => { - const client = await this.connect(); - if (this.status === "authorizing") { - throw new Error("OAuth authorization required. Try Refresh MCP Client"); + try { + const client = await this.connect(); + return await client?.callTool({ + name: toolName, + arguments: input as Record, + }); + } catch (err) { + if (this.status === "authorizing") { + return { + isError: true, + content: [ + { + type: "text", + text: "OAuth authorization required", + }, + ], + _mcpAuthRequired: true, + _mcpServerId: this.id, + }; + } + throw err; } - return client?.callTool({ - name: toolName, - arguments: input as Record, - }); }; return safe(() => this.logger.info("tool call", toolName)) .ifOk(() => this.scheduleAutoDisconnect()) // disconnect if autoDisconnectSeconds is set diff --git a/src/lib/ai/mcp/create-mcp-clients-manager.ts b/src/lib/ai/mcp/create-mcp-clients-manager.ts index a15667240..cee83fbff 100644 --- a/src/lib/ai/mcp/create-mcp-clients-manager.ts +++ b/src/lib/ai/mcp/create-mcp-clients-manager.ts @@ -14,7 +14,6 @@ import { toAny, } from "lib/utils"; import { safe } from "ts-safe"; -import { McpServerTable } from "lib/db/pg/schema.pg"; import { createMCPToolId } from "./mcp-tool-id"; import globalLogger from "logger"; import { jsonSchema, ToolCallOptions } from "ai"; @@ -109,64 +108,89 @@ export class MCPClientsManager { /** * Returns all tools from all clients as a flat object */ - async tools(): Promise> { + async tools(userId?: string): Promise> { await this.waitInitialized(); - return Array.from(this.clients.entries()).reduce( - (acc, [id, client]) => { - if (!client.client?.toolInfo?.length) return acc; - const clientName = client.name; - return { - ...acc, - ...client.client.toolInfo.reduce( - (bcc, tool) => { - return { - ...bcc, - [createMCPToolId(clientName, tool.name)]: - VercelAIMcpToolTag.create({ - description: tool.description, - inputSchema: jsonSchema( - toAny({ - ...tool.inputSchema, - properties: tool.inputSchema?.properties ?? {}, - additionalProperties: false, - }), - ), - _originToolName: tool.name, - _mcpServerName: clientName, - _mcpServerId: id, - execute: (params, options: ToolCallOptions) => { - options?.abortSignal?.throwIfAborted(); - return this.toolCall(id, tool.name, params); - }, - }), - }; + const configs = await this.storage.loadAll(); + + const tools: Record = {}; + + for (const config of configs) { + const { id, name, toolInfo: storedToolInfo, perUserAuth } = config; + const clientId = perUserAuth && userId ? `${id}:${userId}` : id; + const client = this.clients.get(clientId); + + const toolInfo = + client?.client?.toolInfo && client.client.toolInfo.length > 0 + ? client.client.toolInfo + : storedToolInfo || []; + + if (!toolInfo.length) continue; + + const clientName = name; + for (const tool of toolInfo) { + tools[createMCPToolId(clientName, tool.name)] = + VercelAIMcpToolTag.create({ + description: tool.description, + inputSchema: jsonSchema( + toAny({ + ...tool.inputSchema, + properties: tool.inputSchema?.properties ?? {}, + additionalProperties: false, + }), + ), + _originToolName: tool.name, + _mcpServerName: clientName, + _mcpServerId: id, + execute: (params, options: ToolCallOptions) => { + options?.abortSignal?.throwIfAborted(); + return this.toolCall(id, tool.name, params, userId); }, - {} as Record, - ), - }; - }, - {} as Record, - ); + }); + } + } + + return tools; } /** * Creates and adds a new client instance to memory only (no storage persistence) */ - async addClient(id: string, name: string, serverConfig: MCPServerConfig) { - if (this.clients.has(id)) { - const prevClient = this.clients.get(id)!; + async addClient( + id: string, + name: string, + serverConfig: MCPServerConfig, + userId?: string, + ) { + const server = await this.storage.get(id); + const clientId = await this.getClientId(id, userId); + if (this.clients.has(clientId)) { + const prevClient = this.clients.get(clientId)!; void prevClient.client.disconnect(); } const client = createMCPClient(id, name, serverConfig, { autoDisconnectSeconds: this.autoDisconnectSeconds, + userId, + perUserAuth: server?.perUserAuth ?? false, + onToolInfoUpdate: (toolInfo) => { + // Only update storage if it's the main client (not per-user) + // or if we want to share tool info across users + void this.storage.get(id).then((server) => { + if (server) { + void this.storage.save({ + ...server, + toolInfo, + }); + } + }); + }, }); - this.clients.set(id, { client, name }); + this.clients.set(clientId, { client, name }); return client.connect(); } /** * Persists a new client configuration to storage and adds the client instance to memory */ - async persistClient(server: typeof McpServerTable.$inferInsert) { + async persistClient(server: McpServerInsert) { let id = server.name; if (this.storage) { const entity = await this.storage.save(server); @@ -205,15 +229,18 @@ export class MCPClientsManager { /** * Refreshes an existing client with a new configuration or its existing config */ - async refreshClient(id: string) { + async refreshClient(id: string, userId?: string) { await this.waitInitialized(); const server = await this.storage.get(id); if (!server) { throw new Error(`Client ${id} not found`); } - this.logger.info(`Refreshing client ${server.name}`); - await this.addClient(id, server.name, server.config); - return this.clients.get(id)!; + this.logger.info( + `Refreshing client ${server.name}${userId ? ` for user ${userId}` : ""}`, + ); + await this.addClient(id, server.name, server.config, userId); + const clientId = await this.getClientId(id, userId); + return this.clients.get(clientId)!; } async cleanup() { @@ -222,43 +249,77 @@ export class MCPClientsManager { await Promise.allSettled(clients.map(({ client }) => client.disconnect())); } - async getClients() { + async getClients(userId?: string) { await this.waitInitialized(); - return Array.from(this.clients.entries()).map(([id, { client }]) => ({ - id, - client: client, - })); + const configs = await this.storage.loadAll(); + const result: { + id: string; + clientId: string; + client: MCPClient; + name: string; + }[] = []; + + for (const config of configs) { + const clientId = + config.perUserAuth && userId ? `${config.id}:${userId}` : config.id; + const client = this.clients.get(clientId); + if (client) { + result.push({ + id: config.id, + clientId, + client: client.client, + name: client.name, + }); + } + } + + return result; } - async getClient(id: string) { + + private async getClientId(id: string, userId?: string) { + const server = await this.storage.get(id); + if (!server) { + return id; + } + return server.perUserAuth && userId ? `${id}:${userId}` : id; + } + + async getClient(id: string, userId?: string) { await this.waitInitialized(); - const client = this.clients.get(id); + const server = await this.storage.get(id); + if (!server) { + throw new Error(`Client ${id} not found`); + } + + const clientId = await this.getClientId(id, userId); + + const client = this.clients.get(clientId); if (!client) { - await this.refreshClient(id); + await this.addClient(id, server.name, server.config, userId); } - return this.clients.get(id); + return this.clients.get(clientId); } async toolCallByServerName( serverName: string, toolName: string, input: unknown, + userId?: string, ) { - const clients = await this.getClients(); - const client = clients.find((c) => c.client.getInfo().name === serverName); - if (!client) { - if (this.storage) { - const servers = await this.storage.loadAll(); - const server = servers.find((s) => s.name === serverName); - if (server) { - return this.toolCall(server.id, toolName, input); - } - } + const configs = await this.storage.loadAll(); + const server = configs.find((s) => s.name === serverName); + if (!server) { throw new Error(`Client ${serverName} not found`); } - return this.toolCall(client.id, toolName, input); + return this.toolCall(server.id, toolName, input, userId); } - async toolCall(id: string, toolName: string, input: unknown) { - return safe(() => this.getClient(id)) + async toolCall( + id: string, + toolName: string, + input: unknown, + userId?: string, + ) { + return safe(() => this.getClient(id, userId)) .map((client) => { if (!client) throw new Error(`Client ${id} not found`); return client.client; diff --git a/src/lib/ai/mcp/fb-mcp-config-storage.ts b/src/lib/ai/mcp/fb-mcp-config-storage.ts index 5b1d19372..541f34885 100644 --- a/src/lib/ai/mcp/fb-mcp-config-storage.ts +++ b/src/lib/ai/mcp/fb-mcp-config-storage.ts @@ -197,6 +197,8 @@ function fillMcpServerTable( userId: server.userId || "file-based-user", visibility: server.visibility || "private", enabled: true, + perUserAuth: server.perUserAuth ?? false, + toolInfo: server.toolInfo ?? [], createdAt: new Date(), updatedAt: new Date(), }; diff --git a/src/lib/ai/mcp/memory-mcp-config-storage.ts b/src/lib/ai/mcp/memory-mcp-config-storage.ts index fb9ff2b97..f156f0c40 100644 --- a/src/lib/ai/mcp/memory-mcp-config-storage.ts +++ b/src/lib/ai/mcp/memory-mcp-config-storage.ts @@ -29,6 +29,8 @@ export class MemoryMCPConfigStorage implements MCPConfigStorage { config: server.config, userId: server.userId || "test-user", visibility: server.visibility || "private", + perUserAuth: server.perUserAuth || false, + toolInfo: server.toolInfo || [], }; this.configs.set(id, savedServer); return savedServer; diff --git a/src/lib/ai/mcp/pg-oauth-provider.ts b/src/lib/ai/mcp/pg-oauth-provider.ts index 1a851c85e..66e07bba4 100644 --- a/src/lib/ai/mcp/pg-oauth-provider.ts +++ b/src/lib/ai/mcp/pg-oauth-provider.ts @@ -32,6 +32,7 @@ export class PgOAuthClientProvider implements OAuthClientProvider { private config: { name: string; mcpServerId: string; + userId?: string; serverUrl: string; _clientMetadata: OAuthClientMetadata; onRedirectToAuthorization: (authUrl: URL) => Promise; @@ -62,24 +63,31 @@ export class PgOAuthClientProvider implements OAuthClientProvider { } } // 1. Check for authenticated session first + this.logger.info( + `Checking for authenticated session: server=${this.config.mcpServerId}, user=${this.config.userId}`, + ); const authenticated = await pgMcpOAuthRepository.getAuthenticatedSession( this.config.mcpServerId, + this.config.userId, ); if (authenticated) { this.currentOAuthState = authenticated.state || ""; this.cachedAuthData = authenticated; this.initialized = true; - this.logger.info("Using existing authenticated session"); + this.logger.info( + `Using existing authenticated session: state=${this.currentOAuthState}`, + ); return; } - // 2. Always create a new in-progress session when not authenticated + this.logger.info("No authenticated session found, creating new one"); this.currentOAuthState = generateUUID(); this.cachedAuthData = await pgMcpOAuthRepository.createSession( this.config.mcpServerId, { state: this.currentOAuthState, serverUrl: this.config.serverUrl, + userId: this.config.userId, }, ); this.initialized = true; diff --git a/src/lib/db/migrations/pg/0015_abnormal_titania.sql b/src/lib/db/migrations/pg/0015_abnormal_titania.sql new file mode 100644 index 000000000..48ad1b742 --- /dev/null +++ b/src/lib/db/migrations/pg/0015_abnormal_titania.sql @@ -0,0 +1,7 @@ +DROP INDEX "mcp_oauth_session_tokens_idx";--> statement-breakpoint +ALTER TABLE "mcp_oauth_session" ADD COLUMN "user_id" uuid;--> statement-breakpoint +ALTER TABLE "mcp_server" ADD COLUMN "per_user_auth" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "mcp_server" ADD COLUMN "tool_info" json DEFAULT '[]'::json;--> statement-breakpoint +ALTER TABLE "mcp_oauth_session" ADD CONSTRAINT "mcp_oauth_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "mcp_oauth_session_user_id_idx" ON "mcp_oauth_session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "mcp_oauth_session_tokens_idx" ON "mcp_oauth_session" USING btree ("mcp_server_id","user_id") WHERE "mcp_oauth_session"."tokens" is not null; \ No newline at end of file diff --git a/src/lib/db/migrations/pg/meta/0015_snapshot.json b/src/lib/db/migrations/pg/meta/0015_snapshot.json new file mode 100644 index 000000000..7b59edd73 --- /dev/null +++ b/src/lib/db/migrations/pg/meta/0015_snapshot.json @@ -0,0 +1,1645 @@ +{ + "id": "59e0e6d7-7019-440c-9e56-5583b590fe62", + "prevId": "38d89506-17d0-44ef-89dd-625725e3bbfd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_user_id_user_id_fk": { + "name": "agent_user_id_user_id_fk", + "tableFrom": "agent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_item": { + "name": "archive_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archive_id": { + "name": "archive_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "archive_item_item_id_idx": { + "name": "archive_item_item_id_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_item_archive_id_archive_id_fk": { + "name": "archive_item_archive_id_archive_id_fk", + "tableFrom": "archive_item", + "tableTo": "archive", + "columnsFrom": ["archive_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "archive_item_user_id_user_id_fk": { + "name": "archive_item_user_id_user_id_fk", + "tableFrom": "archive_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive": { + "name": "archive", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "archive_user_id_user_id_fk": { + "name": "archive_user_id_user_id_fk", + "tableFrom": "archive", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmark": { + "name": "bookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "bookmark_user_id_idx": { + "name": "bookmark_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmark_item_idx": { + "name": "bookmark_item_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmark_user_id_user_id_fk": { + "name": "bookmark_user_id_user_id_fk", + "tableFrom": "bookmark", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bookmark_user_id_item_id_item_type_unique": { + "name": "bookmark_user_id_item_id_item_type_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "item_id", "item_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_export_comment": { + "name": "chat_export_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_export_comment_export_id_chat_export_id_fk": { + "name": "chat_export_comment_export_id_chat_export_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "chat_export", + "columnsFrom": ["export_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_export_comment_author_id_user_id_fk": { + "name": "chat_export_comment_author_id_user_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_export_comment_parent_id_chat_export_comment_id_fk": { + "name": "chat_export_comment_parent_id_chat_export_comment_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "chat_export_comment", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_export": { + "name": "chat_export", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exporter_id": { + "name": "exporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_thread_id": { + "name": "original_thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_export_exporter_id_user_id_fk": { + "name": "chat_export_exporter_id_user_id_fk", + "tableFrom": "chat_export", + "tableTo": "user", + "columnsFrom": ["exporter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_message_thread_id_chat_thread_id_fk": { + "name": "chat_message_thread_id_chat_thread_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat_thread", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_thread": { + "name": "chat_thread", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_thread_user_id_user_id_fk": { + "name": "chat_thread_user_id_user_id_fk", + "tableFrom": "chat_thread", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_session": { + "name": "mcp_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_info": { + "name": "client_info", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mcp_oauth_session_server_id_idx": { + "name": "mcp_oauth_session_server_id_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_session_user_id_idx": { + "name": "mcp_oauth_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_session_state_idx": { + "name": "mcp_oauth_session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_session_tokens_idx": { + "name": "mcp_oauth_session_tokens_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_oauth_session\".\"tokens\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_session_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_oauth_session_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_oauth_session", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_session_user_id_user_id_fk": { + "name": "mcp_oauth_session_user_id_user_id_fk", + "tableFrom": "mcp_oauth_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_session_state_unique": { + "name": "mcp_oauth_session_state_unique", + "nullsNotDistinct": false, + "columns": ["state"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_custom_instructions": { + "name": "mcp_server_custom_instructions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_custom_instructions_user_id_user_id_fk": { + "name": "mcp_server_custom_instructions_user_id_user_id_fk", + "tableFrom": "mcp_server_custom_instructions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_custom_instructions_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_server_custom_instructions_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_server_custom_instructions", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_server_custom_instructions_user_id_mcp_server_id_unique": { + "name": "mcp_server_custom_instructions_user_id_mcp_server_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "mcp_server_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server": { + "name": "mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "per_user_auth": { + "name": "per_user_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "tool_info": { + "name": "tool_info", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_user_id_user_id_fk": { + "name": "mcp_server_user_id_user_id_fk", + "tableFrom": "mcp_server", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tool_custom_instructions": { + "name": "mcp_server_tool_custom_instructions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_tool_custom_instructions_user_id_user_id_fk": { + "name": "mcp_server_tool_custom_instructions_user_id_user_id_fk", + "tableFrom": "mcp_server_tool_custom_instructions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_tool_custom_instructions_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_server_tool_custom_instructions_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_server_tool_custom_instructions", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_server_tool_custom_instructions_user_id_tool_name_mcp_server_id_unique": { + "name": "mcp_server_tool_custom_instructions_user_id_tool_name_mcp_server_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "tool_name", "mcp_server_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edge": { + "name": "workflow_edge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ui_config": { + "name": "ui_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_edge_workflow_id_workflow_id_fk": { + "name": "workflow_edge_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edge_source_workflow_node_id_fk": { + "name": "workflow_edge_source_workflow_node_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow_node", + "columnsFrom": ["source"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edge_target_workflow_node_id_fk": { + "name": "workflow_edge_target_workflow_node_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow_node", + "columnsFrom": ["target"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_node": { + "name": "workflow_node", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_config": { + "name": "ui_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "node_config": { + "name": "node_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "workflow_node_kind_idx": { + "name": "workflow_node_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_node_workflow_id_workflow_id_fk": { + "name": "workflow_node_workflow_id_workflow_id_fk", + "tableFrom": "workflow_node", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/db/migrations/pg/meta/_journal.json b/src/lib/db/migrations/pg/meta/_journal.json index 3a5bc3a85..3c936b806 100644 --- a/src/lib/db/migrations/pg/meta/_journal.json +++ b/src/lib/db/migrations/pg/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1759110840795, "tag": "0014_faulty_gateway", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1766468535006, + "tag": "0015_abnormal_titania", + "breakpoints": true } ] } diff --git a/src/lib/db/pg/repositories/mcp-oauth-repository.pg.ts b/src/lib/db/pg/repositories/mcp-oauth-repository.pg.ts index 930573a44..d76ae3c4d 100644 --- a/src/lib/db/pg/repositories/mcp-oauth-repository.pg.ts +++ b/src/lib/db/pg/repositories/mcp-oauth-repository.pg.ts @@ -8,13 +8,16 @@ export const pgMcpOAuthRepository: McpOAuthRepository = { // 1. Query methods // Get session with valid tokens (authenticated) - getAuthenticatedSession: async (mcpServerId) => { + getAuthenticatedSession: async (mcpServerId, userId) => { const [session] = await db .select() .from(McpOAuthSessionTable) .where( and( eq(McpOAuthSessionTable.mcpServerId, mcpServerId), + userId + ? eq(McpOAuthSessionTable.userId, userId) + : isNull(McpOAuthSessionTable.userId), isNotNull(McpOAuthSessionTable.tokens), ), ) @@ -85,15 +88,20 @@ export const pgMcpOAuthRepository: McpOAuthRepository = { .where(eq(McpOAuthSessionTable.state, state)) .returning(); - await db - .delete(McpOAuthSessionTable) - .where( - and( - eq(McpOAuthSessionTable.mcpServerId, mcpServerId), - isNull(McpOAuthSessionTable.tokens), - ne(McpOAuthSessionTable.state, state), - ), - ); + if (session) { + await db + .delete(McpOAuthSessionTable) + .where( + and( + eq(McpOAuthSessionTable.mcpServerId, mcpServerId), + session.userId + ? eq(McpOAuthSessionTable.userId, session.userId) + : isNull(McpOAuthSessionTable.userId), + isNull(McpOAuthSessionTable.tokens), + ne(McpOAuthSessionTable.state, state), + ), + ); + } return session as McpOAuthSession; }, diff --git a/src/lib/db/pg/repositories/mcp-repository.pg.ts b/src/lib/db/pg/repositories/mcp-repository.pg.ts index 8bcba97e3..d0d303db1 100644 --- a/src/lib/db/pg/repositories/mcp-repository.pg.ts +++ b/src/lib/db/pg/repositories/mcp-repository.pg.ts @@ -14,6 +14,8 @@ export const pgMcpRepository: MCPRepository = { config: server.config, userId: server.userId, visibility: server.visibility ?? "private", + perUserAuth: server.perUserAuth ?? false, + toolInfo: server.toolInfo ?? [], enabled: true, createdAt: new Date(), updatedAt: new Date(), @@ -22,6 +24,8 @@ export const pgMcpRepository: MCPRepository = { target: [McpServerTable.id], set: { config: server.config, + perUserAuth: server.perUserAuth, + toolInfo: server.toolInfo, updatedAt: new Date(), }, }) @@ -51,6 +55,8 @@ export const pgMcpRepository: MCPRepository = { name: McpServerTable.name, config: McpServerTable.config, enabled: McpServerTable.enabled, + perUserAuth: McpServerTable.perUserAuth, + toolInfo: McpServerTable.toolInfo, userId: McpServerTable.userId, visibility: McpServerTable.visibility, createdAt: McpServerTable.createdAt, @@ -77,6 +83,13 @@ export const pgMcpRepository: MCPRepository = { .where(eq(McpServerTable.id, id)); }, + async updatePerUserAuth(id, perUserAuth) { + await db + .update(McpServerTable) + .set({ perUserAuth, updatedAt: new Date() }) + .where(eq(McpServerTable.id, id)); + }, + async deleteById(id) { await db.delete(McpServerTable).where(eq(McpServerTable.id, id)); }, diff --git a/src/lib/db/pg/schema.pg.ts b/src/lib/db/pg/schema.pg.ts index 5c2e753b9..bcce1db90 100644 --- a/src/lib/db/pg/schema.pg.ts +++ b/src/lib/db/pg/schema.pg.ts @@ -1,6 +1,6 @@ import { Agent } from "app-types/agent"; import { UserPreferences } from "app-types/user"; -import { MCPServerConfig } from "app-types/mcp"; +import { MCPServerConfig, MCPToolInfo } from "app-types/mcp"; import { sql } from "drizzle-orm"; import { pgTable, @@ -84,6 +84,7 @@ export const McpServerTable = pgTable("mcp_server", { name: text("name").notNull(), config: json("config").notNull().$type(), enabled: boolean("enabled").notNull().default(true), + perUserAuth: boolean("per_user_auth").notNull().default(false), userId: uuid("user_id") .notNull() .references(() => UserTable.id, { onDelete: "cascade" }), @@ -92,6 +93,7 @@ export const McpServerTable = pgTable("mcp_server", { }) .notNull() .default("private"), + toolInfo: json("tool_info").$type().default([]), createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); @@ -299,6 +301,9 @@ export const McpOAuthSessionTable = pgTable( mcpServerId: uuid("mcp_server_id") .notNull() .references(() => McpServerTable.id, { onDelete: "cascade" }), + userId: uuid("user_id").references(() => UserTable.id, { + onDelete: "cascade", + }), serverUrl: text("server_url").notNull(), clientInfo: json("client_info"), tokens: json("tokens"), @@ -313,10 +318,11 @@ export const McpOAuthSessionTable = pgTable( }, (t) => [ index("mcp_oauth_session_server_id_idx").on(t.mcpServerId), + index("mcp_oauth_session_user_id_idx").on(t.userId), index("mcp_oauth_session_state_idx").on(t.state), // Partial index for sessions with tokens for better performance index("mcp_oauth_session_tokens_idx") - .on(t.mcpServerId) + .on(t.mcpServerId, t.userId) .where(isNotNull(t.tokens)), ], ); diff --git a/src/types/mcp.ts b/src/types/mcp.ts index 4b0bb6502..2cfa4e223 100644 --- a/src/types/mcp.ts +++ b/src/types/mcp.ts @@ -46,9 +46,11 @@ export type MCPServerInfo = { visibility: "public" | "private"; error?: unknown; enabled: boolean; + perUserAuth: boolean; + isAuthorized?: boolean; userId: string; status: "connected" | "disconnected" | "loading" | "authorizing"; - toolInfo: MCPToolInfo[]; + toolInfo?: MCPToolInfo[]; createdAt?: Date | string; updatedAt?: Date | string; userName?: string | null; @@ -68,6 +70,8 @@ export type McpServerInsert = { id?: string; userId: string; visibility?: "public" | "private"; + perUserAuth?: boolean; + toolInfo?: MCPToolInfo[]; }; export type McpServerSelect = { name: string; @@ -75,6 +79,8 @@ export type McpServerSelect = { id: string; userId: string; visibility: "public" | "private"; + perUserAuth: boolean; + toolInfo?: MCPToolInfo[] | null; }; export type VercelAIMcpTool = Tool & { @@ -94,6 +100,7 @@ export interface MCPRepository { deleteById(id: string): Promise; existsByServerName(name: string): Promise; updateVisibility(id: string, visibility: "public" | "private"): Promise; + updatePerUserAuth(id: string, perUserAuth: boolean): Promise; } export const McpToolCustomizationZodSchema = z.object({ @@ -234,6 +241,8 @@ export const CallToolResultSchema = z.object({ content: z.array(ContentUnion).default([]), structuredContent: z.object({}).passthrough().optional(), isError: z.boolean().optional(), + _mcpAuthRequired: z.boolean().optional(), + _mcpServerId: z.string().optional(), }); export type CallToolResult = z.infer; @@ -241,6 +250,7 @@ export type CallToolResult = z.infer; export type McpOAuthSession = { id: string; mcpServerId: string; + userId?: string | null; serverUrl: string; clientInfo?: OAuthClientInformationFull; tokens?: OAuthTokens; @@ -256,6 +266,7 @@ export type McpOAuthRepository = { // Get session with valid tokens (authenticated) getAuthenticatedSession( mcpServerId: string, + userId?: string, ): Promise; // Get session by OAuth state (for callback handling)