From 07c190856c213c01cb20c1e7adf21cb05298fb14 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:29:29 -0700 Subject: [PATCH 01/40] Add ask MCP server integration --- .../migration.sql | 41 +++ .../migration.sql | 2 + .../migrations/20260326230727_/migration.sql | 24 ++ .../migration.sql | 2 + packages/db/prisma/schema.prisma | 74 +++++ packages/shared/src/env.server.ts | 1 + packages/web/package.json | 3 +- .../[owner]/[repo]/components/landingPage.tsx | 2 +- .../chat/[id]/components/chatThreadPanel.tsx | 9 +- .../chat/components/landingPageChatBox.tsx | 7 +- .../web/src/app/(app)/settings/layout.tsx | 14 +- .../settings/mcpServers/mcpServersPage.tsx | 285 ++++++++++++++++++ .../app/(app)/settings/mcpServers/page.tsx | 14 + packages/web/src/app/api/(client)/client.ts | 33 +- .../web/src/app/api/(server)/chat/route.ts | 5 +- .../api/(server)/ee/askmcp/callback/route.ts | 122 ++++++++ .../api/(server)/ee/askmcp/connect/route.ts | 77 +++++ .../api/(server)/ee/askmcp/connect/types.ts | 4 + .../api/(server)/ee/askmcp/servers/route.ts | 91 ++++++ packages/web/src/ee/features/mcp/actions.ts | 136 +++++++++ .../mcp/components/connectMcpButton.tsx | 58 ++++ .../ee/features/mcp/components/mcpFavicon.tsx | 24 ++ .../ee/features/mcp/mcpClientFactory.test.ts | 132 ++++++++ .../src/ee/features/mcp/mcpClientFactory.ts | 105 +++++++ .../ee/features/mcp/mcpToolRegistry.test.ts | 185 ++++++++++++ .../src/ee/features/mcp/mcpToolRegistry.ts | 99 ++++++ .../src/ee/features/mcp/mcpToolSets.test.ts | 284 +++++++++++++++++ .../web/src/ee/features/mcp/mcpToolSets.ts | 149 +++++++++ .../web/src/ee/features/mcp/utils.test.ts | 36 +++ packages/web/src/ee/features/mcp/utils.ts | 11 + packages/web/src/features/chat/agent.ts | 260 +++++++++++++--- .../components/chatBox/chatBoxPlusButton.tsx | 151 ++++++++++ .../components/chatBox/chatBoxToolbar.tsx | 16 + .../components/chatBox/plusButtonInfoCard.tsx | 15 + .../chat/components/chatThread/chatThread.tsx | 106 ++++++- .../chatThread/chatThreadListItem.tsx | 21 +- .../components/chatThread/detailsCard.tsx | 20 +- .../chatThread/mcpFailedServersBanner.tsx | 43 +++ .../chatThread/toolApprovalBanner.tsx | 101 +++++++ .../chatThread/tools/jsonHighlighter.tsx | 151 ++++++++++ .../chatThread/tools/mcpToolComponent.tsx | 173 +++++++++++ .../chatThread/tools/toolOutputGuard.tsx | 13 +- .../tools/toolSearchToolComponent.tsx | 53 ++++ packages/web/src/features/chat/constants.ts | 1 + .../features/chat/mcpServerIconContext.tsx | 10 + .../src/features/chat/toolApprovalContext.tsx | 9 + packages/web/src/features/chat/types.test.ts | 72 +++++ packages/web/src/features/chat/types.ts | 15 +- .../features/chat/useCreateNewChatThread.ts | 21 +- packages/web/src/features/chat/utils.test.ts | 32 +- packages/web/src/features/chat/utils.ts | 3 +- .../features/mcp/prismaOAuthClientProvider.ts | 168 +++++++++++ packages/web/src/lib/errorCodes.ts | 2 + yarn.lock | 49 ++- 54 files changed, 3451 insertions(+), 83 deletions(-) create mode 100644 packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql create mode 100644 packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql create mode 100644 packages/db/prisma/migrations/20260326230727_/migration.sql create mode 100644 packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql create mode 100644 packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx create mode 100644 packages/web/src/app/(app)/settings/mcpServers/page.tsx create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/connect/types.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts create mode 100644 packages/web/src/ee/features/mcp/actions.ts create mode 100644 packages/web/src/ee/features/mcp/components/connectMcpButton.tsx create mode 100644 packages/web/src/ee/features/mcp/components/mcpFavicon.tsx create mode 100644 packages/web/src/ee/features/mcp/mcpClientFactory.test.ts create mode 100644 packages/web/src/ee/features/mcp/mcpClientFactory.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolRegistry.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolSets.test.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolSets.ts create mode 100644 packages/web/src/ee/features/mcp/utils.test.ts create mode 100644 packages/web/src/ee/features/mcp/utils.ts create mode 100644 packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx create mode 100644 packages/web/src/features/chat/components/chatBox/plusButtonInfoCard.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/jsonHighlighter.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx create mode 100644 packages/web/src/features/chat/mcpServerIconContext.tsx create mode 100644 packages/web/src/features/chat/toolApprovalContext.tsx create mode 100644 packages/web/src/features/chat/types.test.ts create mode 100644 packages/web/src/features/mcp/prismaOAuthClientProvider.ts diff --git a/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql b/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql new file mode 100644 index 000000000..3d3d9966f --- /dev/null +++ b/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "McpServer" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "serverUrl" TEXT NOT NULL, + "clientInfo" TEXT, + "orgId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "McpServerCredential" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "tokens" TEXT, + "codeVerifier" TEXT, + "state" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServerCredential_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId"); + +-- CreateIndex +CREATE UNIQUE INDEX "McpServerCredential_userId_serverId_key" ON "McpServerCredential"("userId", "serverId"); + +-- AddForeignKey +ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "McpServerCredential" ADD CONSTRAINT "McpServerCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "McpServerCredential" ADD CONSTRAINT "McpServerCredential_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql b/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql new file mode 100644 index 000000000..d14625836 --- /dev/null +++ b/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "McpServerCredential_state_idx" ON "McpServerCredential"("state"); diff --git a/packages/db/prisma/migrations/20260326230727_/migration.sql b/packages/db/prisma/migrations/20260326230727_/migration.sql new file mode 100644 index 000000000..b17ca3d7e --- /dev/null +++ b/packages/db/prisma/migrations/20260326230727_/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `McpServer` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "McpServer" DROP COLUMN "name"; + +-- CreateTable +CREATE TABLE "UserMcpServer" ( + "userId" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId") +); + +-- AddForeignKey +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql b/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql new file mode 100644 index 000000000..26f316ab1 --- /dev/null +++ b/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "McpServerCredential" ADD COLUMN "tokensExpiresAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0318f941d..42756b9fe 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -294,6 +294,8 @@ model Org { chats Chat[] repoVisits RepoVisit[] + mcpServers McpServer[] + license License? } @@ -408,6 +410,9 @@ model User { /// claim baked into the JWT cookie at mint time. sessionVersion Int @default(0) + mcpServerCredentials McpServerCredential[] + userMcpServers UserMcpServer[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -642,3 +647,72 @@ model ChangelogEntry { @@index([publishedAt]) } + +/// An external MCP server endpoint, unique per org. +/// Stores the dynamic client registration (client_id/client_secret) once per org. +model McpServer { + id String @id @default(cuid()) + serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp") + + /// Dynamic client registration result (RFC 7591). + /// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at } + /// Null until first user in the org triggers registration. + clientInfo String? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + credentials McpServerCredential[] + userMcpServers UserMcpServer[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([serverUrl, orgId]) +} + +/// Junction table: a user's personal reference to an MCP server with their chosen display name. +model UserMcpServer { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + serverId String + + name String /// User-chosen display name (e.g., "Linear") + + createdAt DateTime @default(now()) + + @@id([userId, serverId]) +} + +/// Per-user OAuth credentials for an external MCP server. +/// Stores tokens (long-lived) and ephemeral auth-flow state separately. +model McpServerCredential { + id String @id @default(cuid()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + serverId String + + /// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens. + tokens String? + + /// Absolute expiry time of the access token, computed at issuance from expires_in. + /// Null when no tokens are stored or the provider did not include expires_in. + tokensExpiresAt DateTime? + + /// PKCE code verifier — ephemeral, only used between redirect and callback. + codeVerifier String? + + /// OAuth state parameter — ephemeral, for CSRF protection during auth flow. + state String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, serverId]) + @@index([state]) +} diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index d9ee5cae3..4be0d4c32 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -282,6 +282,7 @@ const options = { */ SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(), SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100), + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.default(60000), DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'), DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'), diff --git a/packages/web/package.json b/packages/web/package.json index dfed8625f..cd06162c7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "@ai-sdk/deepseek": "^2.0.29", "@ai-sdk/google": "^3.0.64", "@ai-sdk/google-vertex": "^4.0.111", + "@ai-sdk/mcp": "^2.0.0-beta.11", "@ai-sdk/mistral": "^3.0.30", "@ai-sdk/openai": "^3.0.53", "@ai-sdk/openai-compatible": "^2.0.41", @@ -196,7 +197,7 @@ "use-stick-to-bottom": "^1.1.3", "usehooks-ts": "^3.1.0", "vscode-icons-js": "^11.6.1", - "zod": "^3.25.74", + "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx index 5a8a92abc..fe50bc5ca 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx @@ -68,7 +68,7 @@ export const LandingPage = ({
{ - createNewChatThread(children, selectedSearchScopes); + createNewChatThread(children, selectedSearchScopes, []); }} className="min-h-[50px]" isRedirecting={isLoading} diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx index cd1d16b2f..3fe802623 100644 --- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx @@ -42,11 +42,13 @@ export const ChatThreadPanel = ({ localStorage.removeItem(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY); }, []); - // Use the last user's last message to determine what repos and contexts we should select by default. + // Use the last user message to determine what repos, contexts, and MCP state we should select by default. const lastUserMessage = messages.findLast((message) => message.role === "user"); const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? []; + const defaultDisabledMcpServerIds = lastUserMessage?.metadata?.disabledMcpServerIds ?? []; const [selectedSearchScopes, setSelectedSearchScopes] = useState(defaultSelectedSearchScopes); - + const [disabledMcpServerIds, setDisabledMcpServerIds] = useState(defaultDisabledMcpServerIds); + useEffect(() => { if (!chatState) { return; @@ -55,6 +57,7 @@ export const ChatThreadPanel = ({ try { setInputMessage(chatState.inputMessage); setSelectedSearchScopes(chatState.selectedSearchScopes); + setDisabledMcpServerIds(chatState.disabledMcpServerIds); } catch { console.error('Invalid chat state in session storage'); } finally { @@ -74,6 +77,8 @@ export const ChatThreadPanel = ({ searchContexts={searchContexts} selectedSearchScopes={selectedSearchScopes} onSelectedSearchScopesChange={setSelectedSearchScopes} + disabledMcpServerIds={disabledMcpServerIds} + onDisabledMcpServerIdsChange={setDisabledMcpServerIds} isOwner={isOwner} isAuthenticated={isAuthenticated} isLoginWallEnabled={isLoginWallEnabled} diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index d33d2e5b4..55b2d56d5 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -8,7 +8,7 @@ import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useState } from "react"; import { useLocalStorage } from "usehooks-ts"; -import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; +import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { SearchModeSelector } from "../../components/searchModeSelector"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; @@ -29,6 +29,7 @@ export const LandingPageChatBox = ({ }: LandingPageChatBox) => { const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); + const [disabledMcpServerIds, setDisabledMcpServerIds] = useLocalStorage(DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const isChatBoxDisabled = languageModels.length === 0; @@ -37,7 +38,7 @@ export const LandingPageChatBox = ({
{ - createNewChatThread(children); + createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds); }} className="min-h-[50px]" isRedirecting={isLoading} @@ -59,6 +60,8 @@ export const LandingPageChatBox = ({ onSelectedSearchScopesChange={setSelectedSearchScopes} isContextSelectorOpen={isContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen} + disabledMcpServerIds={disabledMcpServerIds} + onDisabledMcpServerIdsChange={setDisabledMcpServerIds} /> icon: "link" as const, } ] : []), - { - title: "MCP Server", - href: `/settings/mcp`, - icon: 'mcp' as const, - } + ...(await hasEntitlement("oauth") ? [ + { + title: "MCP Servers", + href: `/settings/mcpServers`, + icon: "mcp" as const, + } + ] : []), ], }, ]; @@ -129,4 +131,4 @@ export const getSidebarNavGroups = async () => } return groups.filter(g => g.items.length > 0); - }); \ No newline at end of file + }); diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx new file mode 100644 index 000000000..1263a7c02 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useEffect, useRef, useState } from "react"; +import { useToast } from "@/components/hooks/use-toast"; +import { isServiceError } from "@/lib/utils"; +import { createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { getMcpServersWithStatus } from "@/app/api/(client)/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { Loader2, Plus, Server, Trash2 } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; + +function clearCallbackParams() { + const url = new URL(window.location.href); + url.searchParams.delete('status'); + url.searchParams.delete('server'); + url.searchParams.delete('message'); + window.history.replaceState({}, '', url.toString()); +} + +interface McpServersPageProps { + callbackStatus?: string; + callbackServer?: string; + callbackMessage?: string; +} + +export function McpServersPage({ callbackStatus, callbackServer, callbackMessage }: McpServersPageProps) { + const { toast } = useToast(); + const didHandleCallbackRef = useRef(false); + + useEffect(() => { + if (didHandleCallbackRef.current) { + return; + } + if (callbackStatus === 'connected') { + didHandleCallbackRef.current = true; + toast({ description: `Successfully connected${callbackServer ? ` to ${callbackServer}` : ''}.` }); + clearCallbackParams(); + } else if (callbackStatus === 'error') { + didHandleCallbackRef.current = true; + toast({ title: "Connection failed", description: callbackMessage ?? 'Failed to connect MCP server.', variant: "destructive" }); + clearCallbackParams(); + } + }, [callbackStatus, callbackServer, callbackMessage, toast]); + + const queryClient = useQueryClient(); + + const { data: servers = [], isLoading, isError } = useQuery({ + queryKey: ['mcpServersWithStatus'], + queryFn: async () => { + const result = await getMcpServersWithStatus(); + if (isServiceError(result)) { + throw new Error("Failed to load MCP servers"); + } + return result; + }, + }); + + // Create dialog state + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newServerName, setNewServerName] = useState(""); + const [newServerUrl, setNewServerUrl] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + // Delete state + const [deletingServerId, setDeletingServerId] = useState(null); + + const handleCreate = async () => { + if (!newServerUrl.trim()) { + toast({ title: "Error", description: "Server URL is required", variant: "destructive" }); + return; + } + + setIsCreating(true); + try { + const result = await createMcpServer(newServerName.trim(), newServerUrl.trim()); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); + return; + } + await queryClient.invalidateQueries({ queryKey: ['mcpServersWithStatus'] }); + handleCloseCreateDialog(); + } catch (e) { + toast({ title: "Error", description: `Failed to add MCP server: ${e}`, variant: "destructive" }); + } finally { + setIsCreating(false); + } + }; + + const handleCloseCreateDialog = () => { + setIsCreateDialogOpen(false); + setNewServerName(""); + setNewServerUrl(""); + }; + + const handleDelete = async (serverId: string) => { + setDeletingServerId(serverId); + try { + const result = await deleteMcpServer(serverId); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to delete: ${result.message}`, variant: "destructive" }); + return; + } + await queryClient.invalidateQueries({ queryKey: ['mcpServersWithStatus'] }); + } catch (e) { + toast({ title: "Error", description: `Failed to delete MCP server: ${e}`, variant: "destructive" }); + } finally { + setDeletingServerId(null); + } + }; + + if (isError) { + return
Error loading MCP servers
; + } + + return ( +
+ {/* Header + Add button */} +
+
+

MCP Servers

+

+ Connect external MCP servers to use with Ask Sourcebot. +

+
+ + + + + + + + Add MCP Server + +
+
+ + setNewServerName(e.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(e.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+
+
+ + {/* Server list */} + {isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + + + + ))} +
+ ) : servers.length === 0 ? ( + + +
+ +
+

No MCP servers yet

+

+ Click "Add MCP Server" above to connect an external MCP server. +

+
+
+ ) : ( +
+ {servers.map((server) => ( + + +
+
+ +
+ {server.name || server.serverUrl} + {server.serverUrl} +
+
+ + + + + + + Delete MCP Server + + Are you sure you want to remove {server.name || server.serverUrl}? This will remove the server and your credentials from your list. + + + + Cancel + handleDelete(server.id)} + disabled={deletingServerId === server.id} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deletingServerId === server.id ? "Deleting..." : "Delete"} + + + + +
+
+ + {server.isConnected && ( +
+ + Connected +
+ )} + {server.isAuthExpired && ( +
+ + Authorization expired +
+ )} + {!server.isConnected && !server.isAuthExpired && ( +
+ + Not connected +
+ )} +
+ + + +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/mcpServers/page.tsx b/packages/web/src/app/(app)/settings/mcpServers/page.tsx new file mode 100644 index 000000000..edfd780d6 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpServers/page.tsx @@ -0,0 +1,14 @@ +import { McpServersPage } from "./mcpServersPage"; + +interface PageProps { + searchParams: Promise<{ + status?: string; + server?: string; + message?: string; + }>; +} + +export default async function Page({ searchParams }: PageProps) { + const { status, server, message } = await searchParams; + return ; +} diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 0fd119050..41db4e017 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -31,6 +31,8 @@ import type { SearchChatShareableMembersResponse, } from "../(server)/ee/chat/[chatId]/searchMembers/route"; import { OffersResponse } from "@/ee/features/lighthouse/types"; +import { ConnectMcpResponse } from "../(server)/ee/askmcp/connect/types"; +import type { GetMcpServersResponse } from "../(server)/ee/askmcp/servers/route"; export const search = async (body: SearchRequest): Promise => { const result = await fetch("/api/search", { @@ -240,4 +242,33 @@ export const getOffers = async (): Promise => { }).then(response => response.json()); return result as OffersResponse | ServiceError; -} \ No newline at end of file +} + +export const connectMcpToAsk = async (body: { serverId: string }): Promise => { + const result = await fetch('/api/ee/askmcp/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sourcebot-Client-Source': 'sourcebot-web-client', + }, + body: JSON.stringify(body), + }).then(response => response.json()); + + if (isServiceError(result)) { + return result; + } + + return result as ConnectMcpResponse; +} + +export const getMcpServersWithStatus = async (): Promise => { + const result = await fetch('/api/ee/askmcp/servers', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Sourcebot-Client-Source': 'sourcebot-web-client', + }, + }).then(response => response.json()); + + return result as GetMcpServersResponse | ServiceError; +} diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index 4c0b12819..84b3de016 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -33,7 +33,7 @@ export const POST = apiHandler(async (req: NextRequest) => { return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } - const { messages, id, selectedSearchScopes, languageModel: _languageModel } = parsed.data; + const { messages, id, selectedSearchScopes, disabledMcpServerIds, languageModel: _languageModel } = parsed.data; // @note: a bit of type massaging is required here since the // zod schema does not enum on `model` or `provider`. // @see: chat/types.ts @@ -108,10 +108,13 @@ export const POST = apiHandler(async (req: NextRequest) => { selectedSearchScopes, }, selectedRepos: expandedRepos, + disabledMcpServerIds, model, modelName: languageModelConfig.displayName ?? languageModelConfig.model, modelProviderOptions: providerOptions, modelTemperature: temperature, + userId: user?.id, + orgId: org.id, onFinish: async ({ messages }) => { await updateChatMessages({ chatId: id, messages, prisma }); }, diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts new file mode 100644 index 000000000..bd340b9a0 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -0,0 +1,122 @@ +import { auth as mcpAuth } from '@ai-sdk/mcp'; +import { apiHandler } from '@/lib/apiHandler'; +import { env, createLogger } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; +// Note: We use the raw (unscoped) prisma client here because this route handles OAuth +// redirect callbacks from external providers, so it can't go through withAuth. Session +// identity is verified via NextAuth's auth() instead, and all queries filter by userId. +import { __unsafePrisma as prisma } from '@/prisma'; +import { auth } from '@/auth'; +import { NextRequest, NextResponse } from 'next/server'; + +const logger = createLogger('mcp-oauth-callback'); + +export const GET = apiHandler(async (request: NextRequest) => { + if (!(await hasEntitlement('oauth'))) { + return Response.json( + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 403 } + ); + } + + const session = await auth(); + if (!session?.user?.id) { + return Response.json( + { error: 'unauthorized', error_description: 'You must be logged in.' }, + { status: 401 } + ); + } + + const { searchParams } = request.nextUrl; + const oauthError = searchParams.get('error'); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // Handle OAuth errors (e.g., user cancelled the authorization flow). + if (oauthError) { + const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); + settingsUrl.searchParams.set('status', 'error'); + const errorDescription = searchParams.get('error_description') ?? 'Authorization was cancelled or denied.'; + settingsUrl.searchParams.set('message', errorDescription); + return NextResponse.redirect(settingsUrl); + } + + if (!code || !state) { + return Response.json( + { error: 'invalid_request', error_description: 'Missing required parameters: code, state.' }, + { status: 400 } + ); + } + + const credential = await prisma.mcpServerCredential.findFirst({ + where: { + state, + userId: session.user.id, + }, + include: { + server: { + include: { + userMcpServers: { + where: { userId: session.user.id }, + take: 1, + }, + }, + }, + }, + }); + + if (!credential) { + return Response.json( + { error: 'invalid_state', error_description: 'No pending authorization found for this state.' }, + { status: 400 } + ); + } + + const orgMembership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: credential.server.orgId, + userId: session.user.id, + }, + }, + }); + + if (!orgMembership) { + return Response.json( + { error: 'forbidden', error_description: 'You do not have access to this MCP server.' }, + { status: 403 } + ); + } + + const provider = new PrismaOAuthClientProvider( + credential.serverId, + session.user.id, + `${env.AUTH_URL}/api/ee/askmcp/callback`, + ); + + const result = await mcpAuth(provider, { + serverUrl: new URL(credential.server.serverUrl), + authorizationCode: code, + callbackState: state, + }); + + // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. + await provider.invalidateCredentials('verifier'); + + const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); + + if (result === 'AUTHORIZED') { + const displayName = credential.server.userMcpServers[0]?.name ?? credential.server.serverUrl; + logger.info(`Successfully authorized MCP server ${displayName} for user ${session.user.id}.`); + settingsUrl.searchParams.set('status', 'connected'); + settingsUrl.searchParams.set('server', displayName); + return NextResponse.redirect(settingsUrl); + } + + // If auth() didn't return AUTHORIZED, something went wrong + settingsUrl.searchParams.set('status', 'error'); + settingsUrl.searchParams.set('message', 'Token exchange failed'); + return NextResponse.redirect(settingsUrl); +}); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts new file mode 100644 index 000000000..8d0ff1b0e --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -0,0 +1,77 @@ +import { auth as mcpAuth } from '@ai-sdk/mcp'; +import { apiHandler } from '@/lib/apiHandler'; +import { withAuth } from '@/middleware/withAuth'; +import { sew } from '@/middleware/sew'; +import { isServiceError } from '@/lib/utils'; +import { serviceErrorResponse, notFound, requestBodySchemaValidationError } from '@/lib/serviceError'; +import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { hasEntitlement } from '@/lib/entitlements'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { ConnectMcpResponse } from "@/app/api/(server)/ee/askmcp/connect/types"; +import { env } from "@sourcebot/shared"; + +const bodySchema = z.object({ serverId: z.string() }); + +export const POST = apiHandler(async (request: NextRequest) => { + if (!(await hasEntitlement('oauth'))) { + return Response.json( + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 403 } + ); + } + + const body = await request.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); + } + + const result = await sew(() => + withAuth(async ({ user, org, prisma }) => { + const mcpServer = await prisma.mcpServer.findUnique({ + where: { id: parsed.data.serverId, orgId: org.id }, + }); + if (!mcpServer) { + return notFound('MCP server not found'); + } + + // Verify the user has added this server to their list. + const userServer = await prisma.userMcpServer.findUnique({ + where: { + userId_serverId: { + userId: user.id, + serverId: mcpServer.id, + }, + }, + }); + if (!userServer) { + return notFound('MCP server not found'); + } + + const provider = new PrismaOAuthClientProvider( + mcpServer.id, + user.id, + `${env.AUTH_URL}/api/ee/askmcp/callback`, + ); + + const result = await mcpAuth(provider, { + serverUrl: new URL(mcpServer.serverUrl), + }); + + if (result === 'AUTHORIZED') { + // Already has valid tokens (e.g., refreshed) + return { authorizationUrl: null } satisfies ConnectMcpResponse; + } + + return { authorizationUrl: provider.authorizationUrl! } satisfies ConnectMcpResponse; + }) + ); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/types.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/types.ts new file mode 100644 index 000000000..80281ae17 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/types.ts @@ -0,0 +1,4 @@ +export interface ConnectMcpResponse { + /** The external OAuth authorization URL the browser should navigate to. Null if already authorized. */ + authorizationUrl: string | null; +} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts new file mode 100644 index 000000000..8c922faba --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -0,0 +1,91 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { withAuth } from '@/middleware/withAuth'; +import { hasEntitlement } from '@/lib/entitlements'; +import { decryptOAuthToken } from '@sourcebot/shared'; +import { sanitizeMcpServerName } from '@/ee/features/mcp/utils'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import type { OAuthTokens } from '@ai-sdk/mcp'; + +export interface McpServerWithStatus { + id: string; + name: string; + serverUrl: string; + sanitizedName: string; + faviconUrl: string; + isConnected: boolean; + isAuthExpired: boolean; +} + +export type GetMcpServersResponse = McpServerWithStatus[]; + +export const GET = apiHandler(async () => { + if (!(await hasEntitlement('oauth'))) { + return Response.json( + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 403 } + ); + } + + const result = await withAuth(async ({ user, prisma }) => { + const userServers = await prisma.userMcpServer.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + include: { + server: { + include: { + credentials: { + where: { userId: user.id }, + take: 1, + }, + }, + }, + }, + }); + + return userServers.map((us): McpServerWithStatus => { + const credential = us.server.credentials[0] ?? null; + const sanitizedName = sanitizeMcpServerName(us.name); + const origin = new URL(us.server.serverUrl).origin; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; + + let isConnected = false; + let isAuthExpired = false; + + if (credential?.tokens) { + try { + const decrypted = decryptOAuthToken(credential.tokens); + if (decrypted) { + const tokens: OAuthTokens = JSON.parse(decrypted); + if (tokens.refresh_token || !credential.tokensExpiresAt) { + isConnected = true; + } else if (new Date() > credential.tokensExpiresAt) { + isAuthExpired = true; + } else { + isConnected = true; + } + } + } catch { + // treat as not connected if decryption fails + } + } + + return { + id: us.server.id, + name: us.name, + serverUrl: us.server.serverUrl, + sanitizedName, + faviconUrl, + isConnected, + isAuthExpired, + }; + }); + }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts new file mode 100644 index 000000000..f1b827bb8 --- /dev/null +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -0,0 +1,136 @@ +'use server'; + +import { sew } from '@/middleware/sew'; +import { ErrorCode } from '@/lib/errorCodes'; +import { ServiceError } from '@/lib/serviceError'; +import { withAuth } from '@/middleware/withAuth'; +import { StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; +import { sanitizeMcpServerName } from './utils'; + +export const createMcpServer = async (name: string, serverUrl: string) => sew(() => + withAuth(async ({ org, user, prisma }) => { + const urlResult = z.string().url().safeParse(serverUrl); + if (!urlResult.success || !serverUrl.startsWith('https://')) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid server URL. Must be a valid HTTPS URL.', + } satisfies ServiceError; + } + + const sanitizedName = sanitizeMcpServerName(name); + const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; + if (alphanumericCount < 3) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Server name must contain at least 3 alphanumeric characters.', + } satisfies ServiceError; + } + + // Upsert the McpServer record — reuse if the endpoint already exists for this org. + const mcpServer = await prisma.mcpServer.upsert({ + where: { + serverUrl_orgId: { + serverUrl, + orgId: org.id, + }, + }, + update: {}, + create: { + serverUrl, + orgId: org.id, + }, + }); + + // Check if this user already has this server in their list. + const existingUserServer = await prisma.userMcpServer.findUnique({ + where: { + userId_serverId: { + userId: user.id, + serverId: mcpServer.id, + }, + }, + }); + + if (existingUserServer) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: `You have already added an MCP server with URL "${serverUrl}".`, + } satisfies ServiceError; + } + + // Ensure the sanitized name is unique within the user's own servers to prevent + // tool-name collisions (e.g. "My Server" and "My-Server" both become "my_server"). + const userServers = await prisma.userMcpServer.findMany({ + where: { userId: user.id }, + select: { name: true }, + }); + const nameCollision = userServers.some( + (s) => sanitizeMcpServerName(s.name) === sanitizedName + ); + if (nameCollision) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: `You already have an MCP server with a similar name. Please choose a more distinct name.`, + } satisfies ServiceError; + } + + await prisma.userMcpServer.create({ + data: { + userId: user.id, + serverId: mcpServer.id, + name, + }, + }); + + return { + id: mcpServer.id, + name, + serverUrl: mcpServer.serverUrl, + }; + })); + +export const deleteMcpServer = async (serverId: string) => sew(() => + withAuth(async ({ user, prisma }) => { + const userServer = await prisma.userMcpServer.findUnique({ + where: { + userId_serverId: { + userId: user.id, + serverId, + }, + }, + }); + + if (!userServer) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + message: 'MCP server not found', + } satisfies ServiceError; + } + + // Delete the user's reference and their credentials. The McpServer row stays + // because other users may reference the same endpoint. + await prisma.$transaction([ + prisma.mcpServerCredential.deleteMany({ + where: { + userId: user.id, + serverId, + }, + }), + prisma.userMcpServer.delete({ + where: { + userId_serverId: { + userId: user.id, + serverId, + }, + }, + }), + ]); + + return { success: true }; + })); \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx new file mode 100644 index 000000000..d2b00c516 --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState } from 'react'; +import { LoadingButton } from '@/components/ui/loading-button'; +import { useToast } from '@/components/hooks/use-toast'; +import { isServiceError } from '@/lib/utils'; +import { connectMcpToAsk } from '@/app/api/(client)/client'; +import { ExternalLink } from 'lucide-react'; + +interface ConnectMcpButtonProps { + serverId: string; + isConnected?: boolean; + isAuthExpired?: boolean; +} + +export function ConnectMcpButton({ serverId, isConnected, isAuthExpired }: ConnectMcpButtonProps) { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + + const buttonLabel = isConnected || isAuthExpired ? "Reconnect" : "Connect MCP Server"; + const buttonVariant = isConnected ? "outline" as const : undefined; + + const handleConnect = async () => { + setLoading(true); + const result = await connectMcpToAsk({ serverId }); + + if (isServiceError(result)) { + toast({ + description: `Failed to connect MCP server. ${result.message}`, + }); + setLoading(false); + return; + } + + if (result.authorizationUrl) { + // OAuth flow — redirect to the authorization URL + window.location.href = result.authorizationUrl; + // Keep loading=true while redirecting (same pattern as ManageSubscriptionButton) + } else { + // Already authorized + toast({ + description: 'MCP server is already connected.', + }); + setLoading(false); + } + }; + + return ( + + {buttonLabel} + + + ); +} \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/components/mcpFavicon.tsx b/packages/web/src/ee/features/mcp/components/mcpFavicon.tsx new file mode 100644 index 000000000..2220fc516 --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/mcpFavicon.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Plug } from "lucide-react"; +import { useState } from "react"; + +interface McpFaviconProps { + faviconUrl: string | undefined; + className?: string; +} + +export const McpFavicon = ({ faviconUrl, className = "w-4 h-4" }: McpFaviconProps) => { + const [failed, setFailed] = useState(false); + if (faviconUrl && !failed) { + return ( + setFailed(true)} + className={`${className} flex-shrink-0`} + alt="" + /> + ); + } + return ; +}; \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts new file mode 100644 index 000000000..69eefd6d1 --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -0,0 +1,132 @@ +import { expect, test, describe, vi } from 'vitest'; +import { prisma } from '@/__mocks__/prisma'; +import type { OAuthTokens } from '@ai-sdk/mcp'; + +// --- Mocks --- + +vi.mock('@/prisma', async () => { + const actual = await vi.importActual('@/__mocks__/prisma'); + return { ...actual }; +}); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + env: { AUTH_URL: 'http://localhost:3000' }, + decryptOAuthToken: vi.fn((s: string) => s), +})); + +vi.mock('server-only', () => ({ default: vi.fn() })); + +vi.mock('@/features/mcp/prismaOAuthClientProvider', () => ({ + PrismaOAuthClientProvider: vi.fn(), +})); + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})); + +// Import after mocks are set up +const { isTokenExpiredWithNoRefresh, getConnectedMcpClients } = await import('./mcpClientFactory'); + +// --- Helpers --- + +const PAST = new Date('2020-01-01'); +const FUTURE = new Date('2099-01-01'); + +const TOKEN_NO_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer' }; +const TOKEN_WITH_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer', refresh_token: 'ref' }; + +function makeCredential(overrides: { + tokens?: OAuthTokens; + tokensExpiresAt?: Date | null; + orgId?: number; + hasUserServer?: boolean; +}) { + return { + serverId: 'srv-1', + userId: 'user-1', + tokens: JSON.stringify(overrides.tokens ?? TOKEN_NO_REFRESH), + tokensExpiresAt: overrides.tokensExpiresAt ?? null, + codeVerifier: null, + state: null, + server: { + orgId: overrides.orgId ?? 1, + serverUrl: 'https://example.com/mcp', + userMcpServers: overrides.hasUserServer === false ? [] : [{ name: 'MyServer' }], + }, + }; +} + +// --- isTokenExpiredWithNoRefresh --- + +describe('isTokenExpiredWithNoRefresh', () => { + test('returns true when access token is expired and no refresh token', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, PAST)).toBe(true); + }); + + test('returns false when refresh_token is present even if access token is expired', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_WITH_REFRESH, PAST)).toBe(false); + }); + + test('returns false when tokensExpiresAt is null', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, null)).toBe(false); + }); + + test('returns false when access token has not yet expired', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, FUTURE)).toBe(false); + }); +}); + +// --- getConnectedMcpClients --- + +describe('getConnectedMcpClients', () => { + test('skips server when access token expired and no refresh token', async () => { + prisma.mcpServerCredential.findMany.mockResolvedValue([ + makeCredential({ tokens: TOKEN_NO_REFRESH, tokensExpiresAt: PAST }), + ] as never); + + const result = await getConnectedMcpClients('user-1', 1); + expect(result).toHaveLength(0); + }); + + test('includes server when refresh_token present even if access token expired', async () => { + prisma.mcpServerCredential.findMany.mockResolvedValue([ + makeCredential({ tokens: TOKEN_WITH_REFRESH, tokensExpiresAt: PAST }), + ] as never); + + const result = await getConnectedMcpClients('user-1', 1); + expect(result).toHaveLength(1); + }); + + test('includes server when tokensExpiresAt is null', async () => { + prisma.mcpServerCredential.findMany.mockResolvedValue([ + makeCredential({ tokensExpiresAt: null }), + ] as never); + + const result = await getConnectedMcpClients('user-1', 1); + expect(result).toHaveLength(1); + }); + + test('skips server belonging to a different org', async () => { + prisma.mcpServerCredential.findMany.mockResolvedValue([ + makeCredential({ orgId: 999 }), + ] as never); + + const result = await getConnectedMcpClients('user-1', 1); + expect(result).toHaveLength(0); + }); + + test('skips server the user has removed from their list', async () => { + prisma.mcpServerCredential.findMany.mockResolvedValue([ + makeCredential({ hasUserServer: false }), + ] as never); + + const result = await getConnectedMcpClients('user-1', 1); + expect(result).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts new file mode 100644 index 000000000..47f7ee809 --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -0,0 +1,105 @@ +import { __unsafePrisma } from '@/prisma'; +import { createLogger, env, decryptOAuthToken } from '@sourcebot/shared'; +import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { OAuthTokens } from '@ai-sdk/mcp'; + +const logger = createLogger('mcp-client-factory'); + +export interface McpToolSet { + serverId: string; + serverName: string; + serverUrl: string; + transport: StreamableHTTPClientTransport; +} + +/** + * Returns true if the access token is definitely expired and there is no refresh token to fall back on. + */ +export function isTokenExpiredWithNoRefresh(tokens: OAuthTokens, tokensExpiresAt: Date | null): boolean { + if (tokens.refresh_token) { + return false; + } + if (!tokensExpiresAt) { + return false; + } + return new Date() > tokensExpiresAt; +} + +/** + * Creates authenticated transports for all external MCP servers the user has valid credentials for. + * Skips servers with clearly expired tokens and no refresh token. + * Does NOT connect — connection is deferred to createMCPClient. + */ +export async function getConnectedMcpClients(userId: string, orgId: number): Promise { + const credentials = await __unsafePrisma.mcpServerCredential.findMany({ + where: { + userId, + tokens: { not: null }, + }, + include: { + server: { + include: { + userMcpServers: { + where: { userId }, + take: 1, + }, + }, + }, + }, + }); + + const clients: McpToolSet[] = []; + + for (const credential of credentials) { + // Skip servers that don't belong to the current org. + if (credential.server.orgId !== orgId) { + continue; + } + + const userServer = credential.server.userMcpServers[0]; + // Skip if the user has removed this server from their list. + if (!userServer) { + continue; + } + + const serverName = userServer.name; + + try { + const decrypted = decryptOAuthToken(credential.tokens); + if (!decrypted) { + logger.warn(`Could not decrypt tokens for MCP server ${serverName}, skipping.`); + continue; + } + + const tokens: OAuthTokens = JSON.parse(decrypted); + + if (isTokenExpiredWithNoRefresh(tokens, credential.tokensExpiresAt)) { + logger.warn(`Access token for MCP server ${serverName} is expired and has no refresh token. User ${userId} needs to re-authorize.`); + continue; + } + + const provider = new PrismaOAuthClientProvider( + credential.serverId, + userId, + `${env.AUTH_URL}/api/ee/askmcp/callback`, + ); + + const transport = new StreamableHTTPClientTransport( + new URL(credential.server.serverUrl), + { authProvider: provider }, + ); + + clients.push({ + serverId: credential.serverId, + serverName, + serverUrl: credential.server.serverUrl, + transport, + }); + } catch (error) { + logger.error(`Failed to connect to MCP server ${serverName}:`, error); + } + } + + return clients; +} \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts b/packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts new file mode 100644 index 000000000..20918f066 --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts @@ -0,0 +1,185 @@ +import { expect, test, describe } from 'vitest'; +import { buildMcpToolRegistry, searchMcpTools, McpToolRegistryEntry } from './mcpToolRegistry'; + +// Helper to create a mock tool record matching the MCPClient['tools'] return type. +function createToolRecord(tools: Record) { + const record: Record = {}; + for (const [name, tool] of Object.entries(tools)) { + record[name] = { + description: tool.description, + execute: tool.execute ?? (() => {}), + inputSchema: {}, + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return record as any; +} + +describe('buildMcpToolRegistry', () => { + test('extracts serverName from namespaced tool name', () => { + const tools = createToolRecord({ + 'mcp_linear__list_issues': { description: 'List issues' }, + }); + + const registry = buildMcpToolRegistry(tools); + + expect(registry).toEqual([ + { name: 'mcp_linear__list_issues', description: 'List issues', serverName: 'linear' }, + ]); + }); + + test('handles underscores in server name', () => { + const tools = createToolRecord({ + 'mcp_my_server__get_data': { description: 'Get data' }, + }); + + const registry = buildMcpToolRegistry(tools); + + expect(registry[0].serverName).toBe('my_server'); + }); + + test('defaults missing description to empty string', () => { + const tools = createToolRecord({ + 'mcp_linear__list_issues': { description: undefined }, + }); + + const registry = buildMcpToolRegistry(tools); + + expect(registry[0].description).toBe(''); + }); + + test('non-matching tool name yields empty serverName', () => { + const tools = createToolRecord({ + 'some_random_tool': { description: 'A tool' }, + }); + + const registry = buildMcpToolRegistry(tools); + + expect(registry[0].serverName).toBe(''); + }); + + test('empty tools record returns empty array', () => { + const registry = buildMcpToolRegistry(createToolRecord({})); + + expect(registry).toEqual([]); + }); +}); + +describe('searchMcpTools', () => { + // Shared registry for most tests. + const registry: McpToolRegistryEntry[] = [ + { name: 'mcp_linear__list_issues', description: 'List all issues in a project', serverName: 'linear' }, + { name: 'mcp_linear__create_issue', description: 'Create a new issue', serverName: 'linear' }, + { name: 'mcp_linear__update_issue', description: 'Update an existing issue', serverName: 'linear' }, + { name: 'mcp_github__search_repos', description: 'Search repositories on GitHub', serverName: 'github' }, + { name: 'mcp_pg__run_query', description: 'Run a database query', serverName: 'pg' }, + { name: 'mcp_slack__send_message', description: 'Send a message to a Slack channel', serverName: 'slack' }, + { name: 'mcp_jira__create_ticket', description: 'Create a new Jira ticket', serverName: 'jira' }, + ]; + + test('exact name match returns single result', () => { + const results = searchMcpTools('mcp_linear__list_issues', registry); + + expect(results).toEqual([ + { name: 'mcp_linear__list_issues', description: 'List all issues in a project', serverName: 'linear' }, + ]); + }); + + test('token matching on tool name', () => { + const results = searchMcpTools('list issues', registry); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].name).toBe('mcp_linear__list_issues'); + }); + + test('synonym expansion: "find" matches tools with "list"', () => { + const results = searchMcpTools('find issues', registry); + + expect(results.length).toBeGreaterThan(0); + const names = results.map(r => r.name); + expect(names).toContain('mcp_linear__list_issues'); + }); + + test('synonym expansion: "add" matches tools with "create"', () => { + const results = searchMcpTools('add ticket', registry); + + expect(results.length).toBeGreaterThan(0); + const names = results.map(r => r.name); + expect(names).toContain('mcp_jira__create_ticket'); + }); + + test('reverse expansion: canonical "list" expands to synonyms', () => { + // "list" is canonical and expands to "find", "get", "fetch", "search", etc. + const results = searchMcpTools('list repos', registry); + + expect(results.length).toBeGreaterThan(0); + const names = results.map(r => r.name); + // "search_repos" should match because "list" expands to "search" + expect(names).toContain('mcp_github__search_repos'); + }); + + test('higher-scoring entries come first', () => { + // "create issue" should score higher for create_issue than for list_issues + const results = searchMcpTools('create issue', registry); + + expect(results.length).toBeGreaterThan(1); + // The first result should be the one that matches both tokens + expect(results[0].name).toBe('mcp_linear__create_issue'); + }); + + test('topK limits results', () => { + const results = searchMcpTools('issue', registry, 2); + + expect(results.length).toBeLessThanOrEqual(2); + }); + + test('default topK is 5', () => { + // All 7 entries match "mcp" as a substring, but we need tokens > 2 chars + // Use a query that matches many entries + const largeRegistry: McpToolRegistryEntry[] = Array.from({ length: 10 }, (_, i) => ({ + name: `mcp_server__tool_${i}`, + description: `Tool number ${i} for testing`, + serverName: 'server', + })); + + const results = searchMcpTools('tool testing', largeRegistry); + + expect(results.length).toBeLessThanOrEqual(5); + }); + + test('short/empty query fallback returns first topK entries', () => { + // "do it" — all tokens are <= 2 chars after filtering + const results = searchMcpTools('do it', registry); + + expect(results).toEqual(registry.slice(0, 5)); + }); + + test('empty string query fallback returns first topK entries', () => { + const results = searchMcpTools('', registry); + + expect(results).toEqual(registry.slice(0, 5)); + }); + + test('returns empty array when no tokens match', () => { + const results = searchMcpTools('xyznonexistent', registry); + + expect(results).toEqual([]); + }); + + test('search matches in description, not just name', () => { + const results = searchMcpTools('database', registry); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].name).toBe('mcp_pg__run_query'); + }); + + test('tokens shorter than 3 chars are filtered out', () => { + // "do a list" → only "list" survives (length > 2) + const results = searchMcpTools('do a list', registry); + + expect(results.length).toBeGreaterThan(0); + // Should still find results via the "list" token + const names = results.map(r => r.name); + expect(names).toContain('mcp_linear__list_issues'); + }); +}); diff --git a/packages/web/src/ee/features/mcp/mcpToolRegistry.ts b/packages/web/src/ee/features/mcp/mcpToolRegistry.ts new file mode 100644 index 000000000..431710e9e --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolRegistry.ts @@ -0,0 +1,99 @@ +import type { MCPClient } from '@ai-sdk/mcp'; + +export interface McpToolRegistryEntry { + name: string; + description: string; + serverName: string; +} + +type McpToolRecord = Awaited>; + +// Synonym map for common action words. Expands query tokens so that e.g. +// "find tickets" matches a tool named "list_issues". +// Module-level constant — built once at server startup, never re-created. +const SYNONYM_MAP: Record = { + list: ['find', 'get', 'fetch', 'retrieve', 'search', 'show', 'query', 'read'], + create: ['make', 'add', 'post', 'open', 'new', 'submit', 'write'], + update: ['edit', 'modify', 'change', 'patch', 'set'], + delete: ['remove', 'destroy', 'archive', 'close'], + send: ['post', 'publish', 'notify', 'message'], + issue: ['ticket', 'bug', 'task', 'item', 'work'], + comment: ['note', 'reply', 'respond'], + user: ['member', 'person', 'assignee'], + project: ['repo', 'repository', 'workspace'], +}; + +// Reverse lookup: synonym → canonical token. Built once from SYNONYM_MAP. +const REVERSE_SYNONYMS: Record = {}; +for (const [canonical, synonyms] of Object.entries(SYNONYM_MAP)) { + for (const synonym of synonyms) { + REVERSE_SYNONYMS[synonym] = canonical; + } +} + +function expandTokens(tokens: string[]): string[] { + const expanded = new Set(tokens); + for (const token of tokens) { + const canonical = REVERSE_SYNONYMS[token]; + if (canonical) { + expanded.add(canonical); + } + const synonyms = SYNONYM_MAP[token]; + if (synonyms) { + for (const s of synonyms) { + expanded.add(s); + } + } + } + return Array.from(expanded); +} + +export function buildMcpToolRegistry(tools: McpToolRecord): McpToolRegistryEntry[] { + return Object.entries(tools).map(([name, tool]) => { + const match = name.match(/^mcp_(.+?)__/); + const serverName = match ? match[1] : ''; + return { + name, + description: tool.description ?? '', + serverName, + }; + }); +} + +export function searchMcpTools( + query: string, + registry: McpToolRegistryEntry[], + topK = 5, +): McpToolRegistryEntry[] { + // Fast path: if the query is an exact tool name, return it directly. + const exactMatch = registry.find(e => e.name === query); + if (exactMatch) { + return [exactMatch]; + } + + const rawTokens = query + .toLowerCase() + .split(/\W+/) + .filter(t => t.length > 2); + + // If no meaningful tokens remain (e.g. query is "do it" — all tokens <= 2 chars), + // fall back to returning the first topK tools rather than returning nothing. + // We could potentially return nothing or return another tool that will help search better + // in the future. + if (rawTokens.length === 0) { + return registry.slice(0, topK); + } + + const tokens = expandTokens(rawTokens); + + return registry + .map(entry => { + const haystack = `${entry.name} ${entry.description}`.toLowerCase(); + const score = tokens.filter(t => haystack.includes(t)).length; + return { entry, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + .map(({ entry }) => entry); +} \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts new file mode 100644 index 000000000..d49f56986 --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts @@ -0,0 +1,284 @@ +import { expect, test, describe, vi, beforeEach } from 'vitest'; +import type { McpToolSet } from './mcpClientFactory'; + +// --- Mocks --- + +const mockCreateMCPClient = vi.fn(); + +vi.mock('@ai-sdk/mcp', () => ({ + createMCPClient: (...args: unknown[]) => mockCreateMCPClient(...args), +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + env: { + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 5000, + }, +})); + +vi.mock('ai', () => ({ + jsonSchema: vi.fn((schema: unknown, opts: unknown) => ({ schema, ...(opts as object) })), +})); + +// --- Helpers --- + +interface MockToolDef { + name: string; + description?: string; + inputSchema?: Record; + annotations?: Record; +} + +function createMockMcpClient(toolDefs: MockToolDef[]) { + const toolRecord: Record; description: string | undefined; inputSchema: unknown }> = {}; + for (const def of toolDefs) { + toolRecord[def.name] = { + execute: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'result' }] }), + description: def.description, + inputSchema: def.inputSchema ?? {}, + }; + } + + return { + listTools: vi.fn().mockResolvedValue({ tools: toolDefs }), + toolsFromDefinitions: vi.fn().mockReturnValue(toolRecord), + close: vi.fn().mockResolvedValue(undefined), + tools: vi.fn().mockResolvedValue(toolRecord), + }; +} + +function createMockClient(overrides: Partial & { serverName: string }): McpToolSet { + return { + serverId: 'server-id', + serverUrl: `https://${overrides.serverName.toLowerCase()}.example.com/mcp`, + transport: {} as McpToolSet['transport'], + ...overrides, + }; +} + +// --- Tests --- + +// Import after mocks are set up +const { getMcpTools } = await import('./mcpToolSets'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getMcpTools', () => { + test('single server with single tool produces correctly namespaced key', async () => { + const mockClient = createMockMcpClient([ + { name: 'list_issues', description: 'List issues' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + ]); + + expect(Object.keys(result.tools)).toEqual(['mcp_linear__list_issues']); + expect(result.failedServers).toEqual([]); + }); + + test('multiple servers produce tools with distinct prefixes', async () => { + const linearClient = createMockMcpClient([ + { name: 'list_issues', description: 'List issues' }, + ]); + const githubClient = createMockMcpClient([ + { name: 'search_repos', description: 'Search repos' }, + ]); + + mockCreateMCPClient + .mockResolvedValueOnce(linearClient) + .mockResolvedValueOnce(githubClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + createMockClient({ serverName: 'GitHub' }), + ]); + + const toolNames = Object.keys(result.tools); + expect(toolNames).toContain('mcp_linear__list_issues'); + expect(toolNames).toContain('mcp_github__search_repos'); + }); + + test('read-only tool does NOT get needsApproval', async () => { + const mockClient = createMockMcpClient([ + { name: 'list_issues', description: 'List issues', annotations: { readOnlyHint: true } }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__list_issues']; + expect(tool).toBeDefined(); + expect('needsApproval' in tool).toBe(false); + }); + + test('non-read-only tool gets needsApproval: true', async () => { + const mockClient = createMockMcpClient([ + { name: 'create_issue', description: 'Create issue' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__create_issue']; + expect(tool).toBeDefined(); + expect(tool).toHaveProperty('needsApproval', true); + }); + + test('failed server connection adds to failedServers array', async () => { + mockCreateMCPClient.mockRejectedValue(new Error('Connection refused')); + + const result = await getMcpTools([ + createMockClient({ serverName: 'BrokenServer' }), + ]); + + expect(result.failedServers).toEqual(['BrokenServer']); + expect(Object.keys(result.tools)).toEqual([]); + }); + + test('failed server does not prevent other servers from working', async () => { + const goodClient = createMockMcpClient([ + { name: 'list_issues', description: 'List issues' }, + ]); + + mockCreateMCPClient + .mockRejectedValueOnce(new Error('Connection refused')) + .mockResolvedValueOnce(goodClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'BrokenServer' }), + createMockClient({ serverName: 'Linear' }), + ]); + + expect(result.failedServers).toEqual(['BrokenServer']); + expect(Object.keys(result.tools)).toEqual(['mcp_linear__list_issues']); + }); + + test('generates favicon URL from server URL origin', async () => { + const mockClient = createMockMcpClient([ + { name: 'tool', description: 'A tool' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear', serverUrl: 'https://api.linear.app/mcp' }), + ]); + + expect(result.serverFaviconUrls['linear']).toBe( + 'https://www.google.com/s2/favicons?domain=https://api.linear.app&sz=32' + ); + }); + + test('cleanup function calls close on all clients', async () => { + const client1 = createMockMcpClient([{ name: 'tool1', description: 'Tool 1' }]); + const client2 = createMockMcpClient([{ name: 'tool2', description: 'Tool 2' }]); + + mockCreateMCPClient + .mockResolvedValueOnce(client1) + .mockResolvedValueOnce(client2); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Server1' }), + createMockClient({ serverName: 'Server2' }), + ]); + + await result.cleanup(); + + expect(client1.close).toHaveBeenCalledOnce(); + expect(client2.close).toHaveBeenCalledOnce(); + }); + + test('cleanup handles errors in close gracefully', async () => { + const client1 = createMockMcpClient([{ name: 'tool1', description: 'Tool 1' }]); + const client2 = createMockMcpClient([{ name: 'tool2', description: 'Tool 2' }]); + client1.close.mockRejectedValue(new Error('Close failed')); + + mockCreateMCPClient + .mockResolvedValueOnce(client1) + .mockResolvedValueOnce(client2); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Server1' }), + createMockClient({ serverName: 'Server2' }), + ]); + + // Should not throw + await expect(result.cleanup()).resolves.toBeUndefined(); + expect(client2.close).toHaveBeenCalledOnce(); + }); + + test('empty clients array returns empty result', async () => { + const result = await getMcpTools([]); + + expect(result.tools).toEqual({}); + expect(result.failedServers).toEqual([]); + expect(result.serverFaviconUrls).toEqual({}); + expect(typeof result.cleanup).toBe('function'); + }); + + test('tool schema validation rejects invalid input', async () => { + const mockClient = createMockMcpClient([ + { + name: 'create_issue', + description: 'Create issue', + inputSchema: { + type: 'object', + properties: { title: { type: 'string' } }, + }, + }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__create_issue']; + // The inputSchema should have a validate function from our jsonSchema mock + const schema = tool.inputSchema as { validate?: (value: unknown) => Promise<{ success: boolean; error?: Error }> }; + expect(schema.validate).toBeDefined(); + + if (schema.validate) { + // Valid input + const validResult = await schema.validate({ title: 'My Issue' }); + expect(validResult.success).toBe(true); + + // Invalid input (extra property not allowed because additionalProperties: false) + const invalidResult = await schema.validate({ title: 'My Issue', bogus: 'field' }); + expect(invalidResult.success).toBe(false); + } + }); + + test('tool execute wrapper propagates non-timeout errors', async () => { + const originalError = new Error('External API failed'); + const mockClient = createMockMcpClient([ + { name: 'create_issue', description: 'Create issue' }, + ]); + // Override the execute to reject + const toolRecord = mockClient.toolsFromDefinitions(); + toolRecord['create_issue'].execute.mockRejectedValue(originalError); + + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__create_issue']; + await expect( + tool.execute({}, { messages: [], toolCallId: 'test' }) + ).rejects.toThrow('External API failed'); + }); +}); diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts new file mode 100644 index 000000000..91a235b8b --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -0,0 +1,149 @@ +import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'; +import { McpToolSet } from './mcpClientFactory'; +import { createLogger, env } from '@sourcebot/shared'; +import { sanitizeMcpServerName } from './utils'; +import Ajv from 'ajv'; +import { jsonSchema, ToolExecutionOptions } from 'ai'; +import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; + +const logger = createLogger('mcp-tool-sets'); +const ajv = new Ajv({ allErrors: true, strict: false }); + +class McpToolTimeoutError extends Error { + constructor(toolName: string, timeoutMs: number) { + super(`MCP tool "${toolName}" timed out after ${timeoutMs}ms`); + this.name = 'McpToolTimeoutError'; + } +} + +export interface McpToolsResult { + tools: Record>[string]>; + failedServers: string[]; + serverFaviconUrls: Record; + cleanup: () => Promise; +} + +/** + * Creates MCPClients from authenticated transports, retrieves their tools, + * and returns a namespaced tool record + cleanup function. + */ +export async function getMcpTools(clients: McpToolSet[]): Promise { + const allTools: McpToolsResult['tools'] = {}; + const failedServers: string[] = []; + const serverFaviconUrls: Record = {}; + const mcpClients: MCPClient[] = []; + + const connectionTimeoutMs = env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + + for (const { serverName, serverUrl, transport } of clients) { + try { + const mcpClient = await Promise.race([ + createMCPClient({ transport }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Connection to MCP server "${serverName}" timed out after ${connectionTimeoutMs}ms`)), connectionTimeoutMs) + ), + ]); + mcpClients.push(mcpClient); + + const toolDefinitions = await Promise.race([ + mcpClient.listTools(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Listing tools from MCP server "${serverName}" timed out after ${connectionTimeoutMs}ms`)), connectionTimeoutMs) + ), + ]); + const tools = mcpClient.toolsFromDefinitions(toolDefinitions); + const sanitizedName = sanitizeMcpServerName(serverName); + const prefix = `mcp_${sanitizedName}`; + + for (const [toolName, tool] of Object.entries(tools)) { + const def = toolDefinitions.tools.find(t => t.name === toolName); + const isReadOnly = (def?.annotations as Record | undefined)?.readOnlyHint === true; + + // The @ai-sdk/mcp library sets additionalProperties: false in the JSON schema + // sent to the model, but does NOT provide a validate function — so the AI SDK + // skips server-side validation entirely. We compile the schema with ajv to + // enforce parameter names at runtime, which allows experimental_repairToolCall + // to fire on InvalidToolInputError. + const rawSchema = def?.inputSchema ?? { type: 'object', properties: {} }; + const schema = { + ...rawSchema, + type: 'object' as const, + properties: (rawSchema.properties ?? {}) as Record, + additionalProperties: false, + } satisfies JSONSchema7; + const validate = ajv.compile(schema); + const validProperties = Object.keys(schema.properties); + const validatedInputSchema = jsonSchema(schema, { + validate: async (value: unknown) => { + if (validate(value)) { + return { success: true as const, value }; + } + return { + success: false as const, + error: new Error( + `${ajv.errorsText(validate.errors)}. The valid parameter names for this tool are: [${validProperties.join(', ')}]` + ), + }; + }, + }); + + const originalExecute = tool.execute; + const qualifiedName = `${prefix}__${toolName}`; + const timeoutMs = env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + + const executeWithTimeout = (async (input: unknown, options: ToolExecutionOptions) => { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const combinedSignal = options.abortSignal + ? AbortSignal.any([options.abortSignal, timeoutSignal]) + : timeoutSignal; + + try { + return await originalExecute(input, { + ...options, + abortSignal: combinedSignal, + }); + } catch (error) { + if (timeoutSignal.aborted) { + logger.warn(`MCP tool "${qualifiedName}" timed out after ${timeoutMs}ms`); + throw new McpToolTimeoutError(qualifiedName, timeoutMs); + } + throw error; + } + }) as typeof originalExecute; + + allTools[qualifiedName] = { + ...tool, + execute: executeWithTimeout, + // The @ai-sdk/mcp package bundles its own copy of @ai-sdk/provider-utils, + // so its Schema isn't structurally identical to the workspace copy. + // The runtime shape is the same; cast through `any` to bridge the duplicate + // type identity (the two FlexibleSchema types differ only by their internal + // schemaSymbol brand). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema: validatedInputSchema as any, + ...(isReadOnly ? {} : { needsApproval: true }), + }; + } + + const origin = new URL(serverUrl).origin; + serverFaviconUrls[sanitizedName] = `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; + } catch (error) { + logger.error(`Failed to get tools from MCP server ${serverName}:`, error); + failedServers.push(serverName); + } + } + + const cleanup = async () => { + await Promise.allSettled( + mcpClients.map(async (client) => { + try { + await client.close(); + } catch (error) { + logger.error('Error closing MCP client:', error); + } + }) + ); + }; + + return { tools: allTools, failedServers, serverFaviconUrls, cleanup }; +} \ No newline at end of file diff --git a/packages/web/src/ee/features/mcp/utils.test.ts b/packages/web/src/ee/features/mcp/utils.test.ts new file mode 100644 index 000000000..c4a63ffc3 --- /dev/null +++ b/packages/web/src/ee/features/mcp/utils.test.ts @@ -0,0 +1,36 @@ +import { expect, test, describe } from 'vitest'; +import { sanitizeMcpServerName } from './utils'; + +describe('sanitizeMcpServerName', () => { + test('lowercases ASCII letters', () => { + expect(sanitizeMcpServerName('MyServer')).toBe('myserver'); + }); + + test('replaces special characters with underscores', () => { + expect(sanitizeMcpServerName('My Server!')).toBe('my_server_'); + }); + + test('preserves digits', () => { + expect(sanitizeMcpServerName('server123')).toBe('server123'); + }); + + test('replaces spaces and hyphens', () => { + expect(sanitizeMcpServerName('my-cool server')).toBe('my_cool_server'); + }); + + test('handles empty string', () => { + expect(sanitizeMcpServerName('')).toBe(''); + }); + + test('replaces unicode characters with underscores', () => { + expect(sanitizeMcpServerName('Ñoño')).toBe('_o_o'); + }); + + test('replaces all special characters', () => { + expect(sanitizeMcpServerName('@#$%')).toBe('____'); + }); + + test('returns already sanitized name unchanged', () => { + expect(sanitizeMcpServerName('linear')).toBe('linear'); + }); +}); diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts new file mode 100644 index 000000000..3a0176dba --- /dev/null +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -0,0 +1,11 @@ +/** + * Sanitizes an MCP server name into a lowercase alphanumeric string suitable + * for use as a tool-name prefix (e.g. "My Server!" → "my_server_"). + * + * This is used to namespace MCP tools (mcp_{sanitizedName}__{toolName}) and + * to key favicon maps. Must be kept consistent everywhere — collisions on + * this value are prevented at server-creation time. + */ +export function sanitizeMcpServerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, '_'); +} \ No newline at end of file diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 0efb706fc..412c98c28 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -6,17 +6,26 @@ import { LanguageModelV3 as AISDKLanguageModelV3 } from "@ai-sdk/provider"; import { ProviderOptions } from "@ai-sdk/provider-utils"; import { createLogger, env } from "@sourcebot/shared"; import { + convertToModelMessages, createUIMessageStream, JSONValue, LanguageModel, ModelMessage, StopCondition, streamText, StreamTextResult, UIMessageStreamOnFinishCallback, UIMessageStreamOptions, - UIMessageStreamWriter + UIMessageStreamWriter, + tool, + Tool, + NoSuchToolError, } from "ai"; +import { z } from "zod"; import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import { createTools } from "./tools"; +import { getConnectedMcpClients } from "@/ee/features/mcp/mcpClientFactory"; +import { getMcpTools, McpToolsResult } from "@/ee/features/mcp/mcpToolSets"; +import { buildMcpToolRegistry, McpToolRegistryEntry, searchMcpTools } from "@/ee/features/mcp/mcpToolRegistry"; +import { hasEntitlement } from '@/lib/entitlements'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -36,6 +45,9 @@ interface CreateMessageStreamResponseProps { chatId: string; messages: SBChatMessage[]; selectedRepos: string[]; + // When undefined, MCP tools are disabled entirely (e.g. programmatic callers like askCodebase). + // When an array, MCP tools are enabled for all servers not in the list. + disabledMcpServerIds?: string[]; model: AISDKLanguageModelV3; modelName: string; onFinish: UIMessageStreamOnFinishCallback; @@ -43,6 +55,8 @@ interface CreateMessageStreamResponseProps { modelProviderOptions?: Record>; modelTemperature?: number; metadata?: Partial; + userId?: string; + orgId?: number; } export const createMessageStream = async ({ @@ -50,12 +64,15 @@ export const createMessageStream = async ({ messages, metadata, selectedRepos, + disabledMcpServerIds, model, modelName, modelProviderOptions, modelTemperature, onFinish, onError, + userId, + orgId, }: CreateMessageStreamResponseProps) => { const latestMessage = messages[messages.length - 1]; const sources = latestMessage.parts @@ -66,7 +83,7 @@ export const createMessageStream = async ({ // Extract user messages and assistant answers. // We will use this as the context we carry between messages. - const messageHistory = + let messageHistory: ModelMessage[] = messages.map((message): ModelMessage | undefined => { if (message.role === 'user') { return { @@ -86,6 +103,28 @@ export const createMessageStream = async ({ } }).filter(message => message !== undefined); + // When the last assistant turn has approval responses (from the tool approval flow), + // the turn is incomplete — it has no answer text, only a pending tool call that was + // approved. We need to preserve the full tool call + approval so streamText can + // execute the approved tool and continue. + const lastMsg = messages[messages.length - 1]; + const hasApprovalResponses = lastMsg?.role === 'assistant' && + lastMsg.parts.some(p => p.type === 'dynamic-tool' && p.state === 'approval-responded'); + + // When continuing after tool approval, capture the prior turn's metadata + // so we can aggregate token counts and response times across phases. + const priorMetadata = hasApprovalResponses + ? (lastMsg.metadata as SBChatMessageMetadata | undefined) + : undefined; + + if (hasApprovalResponses) { + const fullLastTurn = await convertToModelMessages( + [lastMsg], + { ignoreIncompleteToolCalls: true } + ); + messageHistory = [...messageHistory, ...fullLastTurn]; + } + const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.write({ @@ -101,17 +140,33 @@ export const createMessageStream = async ({ inputMessages: messageHistory, inputSources: sources, selectedRepos, + disabledMcpServerIds, onWriteSource: (source) => { writer.write({ type: 'data-source', data: source, }); }, + onMcpServerDiscovered: (sanitizedName, faviconUrl) => { + writer.write({ + type: 'data-mcp-server', + data: { sanitizedName, faviconUrl }, + }); + }, + onMcpServerFailed: (serverName) => { + writer.write({ + type: 'data-mcp-failed-server', + data: { serverName }, + }); + }, traceId, chatId, + userId, + orgId, }); await mergeStreamAsync(researchStream, writer, { + originalMessages: messages, sendReasoning: true, sendStart: false, sendFinish: false, @@ -122,10 +177,10 @@ export const createMessageStream = async ({ writer.write({ type: 'message-metadata', messageMetadata: { - totalTokens: totalUsage.totalTokens, - totalInputTokens: totalUsage.inputTokens, - totalOutputTokens: totalUsage.outputTokens, - totalResponseTimeMs: new Date().getTime() - startTime.getTime(), + totalTokens: (priorMetadata?.totalTokens ?? 0) + (totalUsage.totalTokens ?? 0), + totalInputTokens: (priorMetadata?.totalInputTokens ?? 0) + (totalUsage.inputTokens ?? 0), + totalOutputTokens: (priorMetadata?.totalOutputTokens ?? 0) + (totalUsage.outputTokens ?? 0), + totalResponseTimeMs: (priorMetadata?.totalResponseTimeMs ?? 0) + (new Date().getTime() - startTime.getTime()), modelName, traceId, ...metadata, @@ -149,11 +204,16 @@ interface AgentOptions { providerOptions?: ProviderOptions; temperature?: number; selectedRepos: string[]; + disabledMcpServerIds?: string[]; inputMessages: ModelMessage[]; inputSources: Source[]; onWriteSource: (source: Source) => void; + onMcpServerDiscovered: (sanitizedName: string, faviconUrl: string) => void; + onMcpServerFailed: (serverName: string) => void; traceId: string; chatId: string; + userId?: string; + orgId?: number; } const createAgentStream = async ({ @@ -163,9 +223,14 @@ const createAgentStream = async ({ inputMessages, inputSources, selectedRepos, + disabledMcpServerIds, onWriteSource, + onMcpServerDiscovered, + onMcpServerFailed, traceId, chatId, + userId, + orgId, }: AgentOptions) => { // For every file source, resolve the source code so that we can include it in the system prompt. const fileSources = inputSources.filter((source) => source.type === 'file'); @@ -192,48 +257,162 @@ const createAgentStream = async ({ })) ).filter((source) => source !== undefined); + let mcpToolSetsObj: McpToolsResult = { tools: {}, failedServers: [], serverFaviconUrls: {}, cleanup: async () => {} }; + if (userId && orgId && await hasEntitlement('oauth') && disabledMcpServerIds !== undefined) { + try { + const allMcpClients = await getConnectedMcpClients(userId, orgId); + const mcpClients = allMcpClients.filter((c) => !disabledMcpServerIds.includes(c.serverId)); + mcpToolSetsObj = await getMcpTools(mcpClients); + + for (const [sanitizedName, faviconUrl] of Object.entries(mcpToolSetsObj.serverFaviconUrls)) { + onMcpServerDiscovered(sanitizedName, faviconUrl); + } + + if (mcpClients.length > 0) { + logger.info(`Connected to ${mcpClients.length} external MCP server(s): ${mcpClients.map(c => c.serverName).join(', ')}`); + } + } catch (error) { + logger.error('Failed to connect external MCP servers:', error); + } + } + + for (const serverName of mcpToolSetsObj.failedServers) { + onMcpServerFailed(serverName); + } + + const mcpRegistry = buildMcpToolRegistry(mcpToolSetsObj.tools); + const hasMcpTools = mcpRegistry.length > 0; + + const toolRequestActivation = tool({ + description: dedent` + Activate an MCP tool by name so it becomes callable on your next step. + You MUST pass an exact tool name from the tool registry in the system prompt. + Do NOT pass natural language descriptions or sentences. + If you need multiple tools, call this once per tool. + + Examples: + CORRECT: tool_to_activate_name="mcp_linear__save_comment" + CORRECT: tool_to_activate_name="mcp_linear__create_attachment" + INCORRECT: tool_to_activate_name="create a linear issue and update status" + INCORRECT: tool_to_activate_name="find tools for commenting on issues" + `, + inputSchema: z.object({ + tool_to_activate_name: z.string().describe('Exact tool name from the registry, e.g. "mcp_linear__save_comment"'), + }), + execute: async ({ tool_to_activate_name }) => { + const results = searchMcpTools(tool_to_activate_name, mcpRegistry); + return { + results: results.map(e => ({ name: e.name, description: e.description })), + }; + }, + }); + const systemPrompt = createPrompt({ repos: selectedRepos, files: resolvedFileSources, + mcpToolRegistry: mcpRegistry, }); - const stream = streamText({ - model, - providerOptions, - messages: inputMessages, - system: systemPrompt, - tools: createTools({ source: 'sourcebot-ask-agent', selectedRepos }), - temperature: temperature ?? env.SOURCEBOT_CHAT_MODEL_TEMPERATURE, - stopWhen: [ - stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT), - ], - toolChoice: "auto", - onStepFinish: ({ toolResults }) => { - toolResults.forEach(({ output, dynamic }) => { - if (dynamic || isServiceError(output)) { - return; + const builtinTools = createTools({ source: 'sourcebot-ask-agent', selectedRepos }); + const builtinToolNames = Object.keys(builtinTools); + const allTools: Record = { + ...builtinTools, + ...(hasMcpTools ? { tool_request_activation: toolRequestActivation, ...mcpToolSetsObj.tools } : {}), + }; + + try { + const stream = streamText({ + model, + providerOptions, + messages: inputMessages, + system: systemPrompt, + tools: allTools, + activeTools: [ + ...builtinToolNames, + ...(hasMcpTools ? ['tool_request_activation'] : []), + ], + prepareStep: hasMcpTools ? ({ steps }) => { + const activated = new Set(); + for (const step of steps) { + for (const result of step.toolResults) { + if (!result || result.toolName !== 'tool_request_activation') { + continue; + } + const output = result.output as { results?: Array<{ name: string }> }; + for (const { name } of output?.results ?? []) { + if (name in mcpToolSetsObj.tools) { + activated.add(name); + } + } + } + } + return { + activeTools: [ + ...builtinToolNames, + 'tool_request_activation', + ...Array.from(activated), + ], + }; + } : undefined, + temperature: temperature ?? env.SOURCEBOT_CHAT_MODEL_TEMPERATURE, + stopWhen: [ + stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT), + ], + toolChoice: "auto", + experimental_repairToolCall: async ({ toolCall, tools, error }) => { + // Fix case mismatches (e.g. model outputs "Mcp_Linear__Save_Comment" instead of "mcp_linear__save_comment") + if (NoSuchToolError.isInstance(error)) { + const lower = toolCall.toolName.toLowerCase(); + if (lower !== toolCall.toolName && lower in tools) { + return { ...toolCall, toolName: lower }; + } } - output.sources?.forEach(onWriteSource); - }); - }, - experimental_telemetry: { - isEnabled: env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', - metadata: { - langfuseTraceId: traceId, + // For anything we can't fix, return null. + // The AI SDK will mark the call as invalid and pass the error + // back to the model so it can retry with correct parameters. + logger.warn(`Tool call repair failed for "${toolCall.toolName}": ${error.message}`); + return null; }, - }, - onError: (error) => { - logger.error(error); - }, - }); + onStepFinish: ({ toolResults }) => { + toolResults.forEach(({ output, dynamic }) => { + if (dynamic || isServiceError(output)) { + return; + } - return stream; + output.sources?.forEach(onWriteSource); + }); + }, + experimental_telemetry: { + isEnabled: env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', + metadata: { + langfuseTraceId: traceId, + }, + }, + onError: (error) => { + logger.error(error); + }, + }); + + // Clean up MCP transport connections once the stream completes (success or failure). + stream.response.then( + () => mcpToolSetsObj.cleanup(), + () => mcpToolSetsObj.cleanup() + ); + return stream; + } catch (error) { + // If anything between MCP setup and stream return throws, ensure we + // still close the MCP transport connections to avoid leaking them. + await mcpToolSetsObj.cleanup(); + throw error; + } } + const createPrompt = ({ files, repos, + mcpToolRegistry, }: { files?: { path: string; @@ -243,6 +422,7 @@ const createPrompt = ({ revision: string; }[], repos: string[], + mcpToolRegistry: McpToolRegistryEntry[], }) => { return dedent` You are a powerful agentic AI code assistant built into Sourcebot, the world's best code-intelligence platform. Your job is to help developers understand and navigate their large codebases. @@ -287,6 +467,18 @@ const createPrompt = ({ `: ''} + ${(mcpToolRegistry.length > 0) ? dedent` + + External MCP tools are available but must first be activated via \`tool_request_activation\`. + + **CRITICAL**: The list below is the complete and authoritative inventory of all tools available to you: + ${mcpToolRegistry.map(e => `- ${e.name}: ${e.description}`).join('\n')} + + **How to use tool_request_activation**: Pass the exact tool name from the list above as the \`tool_to_activate_name\` parameter. Do NOT pass natural language descriptions or sentences. If you need multiple tools, call \`tool_request_activation\` once per tool. + Example: to activate the comment tool, call \`tool_request_activation\` with tool_to_activate_name="mcp_linear__save_comment", NOT tool_to_activate_name="save a comment on an issue". + + ` : ''} + When you have sufficient context, output your answer as a structured markdown response. diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx new file mode 100644 index 000000000..882e75ce2 --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; +import { getMcpServersWithStatus } from "@/app/api/(client)/client"; +import { isServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { AlertTriangleIcon, Plug, PlusIcon, RefreshCwIcon, ServerIcon, SettingsIcon } from "lucide-react"; +import { PlusButtonInfoCard } from "./plusButtonInfoCard"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +interface ChatBoxPlusButtonProps { + disabledMcpServerIds: string[]; + onDisabledMcpServerIdsChange: (ids: string[]) => void; +} + +export const ChatBoxPlusButton = ({ + disabledMcpServerIds, + onDisabledMcpServerIdsChange, +}: ChatBoxPlusButtonProps) => { + const [failedFavicons, setFailedFavicons] = useState>(new Set()); + const router = useRouter(); + + const { data: servers, isError, refetch } = useQuery({ + queryKey: ['mcpServersWithStatus'], + queryFn: async () => { + const result = await getMcpServersWithStatus(); + if (isServiceError(result)) { + throw new Error("Failed to load MCP servers"); + } + return result; + }, + }); + + const onToggle = (serverId: string, checked: boolean) => { + if (checked) { + onDisabledMcpServerIdsChange(disabledMcpServerIds.filter((id) => id !== serverId)); + } else { + onDisabledMcpServerIdsChange([...disabledMcpServerIds, serverId]); + } + }; + + const onFaviconError = (serverId: string) => { + setFailedFavicons((prev) => new Set(prev).add(serverId)); + }; + + // Only surface servers the user has attempted to connect (connected or auth expired). + const relevantServers = servers?.filter((s) => s.isConnected || s.isAuthExpired) ?? []; + + return ( + + + + + + + + + + + + e.preventDefault()}> + + + + MCP Servers + + + {isError && relevantServers.length === 0 ? ( + { + e.preventDefault(); + refetch(); + }} + className="gap-2 text-destructive" + > + + Failed to load. Retry? + + ) : relevantServers.length === 0 ? ( + + No MCP servers connected + + ) : ( + relevantServers.map((server) => { + const isEnabled = !server.isAuthExpired && !disabledMcpServerIds.includes(server.id); + return ( + e.preventDefault()} + disabled={server.isAuthExpired} + className="flex items-center justify-between gap-2" + > +
+ {server.isAuthExpired ? ( + + ) : failedFavicons.has(server.id) ? ( + + ) : ( + // eslint-disable-next-line @next/next/no-img-element + onFaviconError(server.id)} + className="w-4 h-4 shrink-0 rounded-sm" + alt="" + /> + )} + {server.name} +
+ onToggle(server.id, checked)} + disabled={server.isAuthExpired} + className="scale-75" + /> +
+ ); + }) + )} + + router.push(`/settings/mcpServers`)} + > + + Manage MCP servers + +
+
+
+
+ ); +}; diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx index a0aae38cf..280f7f9bf 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx @@ -5,6 +5,7 @@ import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useSelectedLanguageModel } from "../../useSelectedLanguageModel"; import { AtMentionButton } from "./atMentionButton"; +import { ChatBoxPlusButton } from "./chatBoxPlusButton"; import { LanguageModelSelector } from "./languageModelSelector"; import { SearchScopeSelector } from "./searchScopeSelector"; @@ -16,6 +17,10 @@ export interface ChatBoxToolbarProps { onSelectedSearchScopesChange: (items: SearchScope[]) => void; isContextSelectorOpen: boolean; onContextSelectorOpenChanged: (isOpen: boolean) => void; + // TODO_Jack_MakeLinearTask: Make the plus button available on simplified toolbar usages (e.g. askgh) + // once additional features (beyond MCP server toggling) are added to it. + disabledMcpServerIds?: string[]; + onDisabledMcpServerIdsChange?: (ids: string[]) => void; } export const ChatBoxToolbar = ({ @@ -26,6 +31,8 @@ export const ChatBoxToolbar = ({ onSelectedSearchScopesChange, isContextSelectorOpen, onContextSelectorOpenChanged, + disabledMcpServerIds, + onDisabledMcpServerIdsChange, }: ChatBoxToolbarProps) => { const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({ languageModels, @@ -33,6 +40,15 @@ export const ChatBoxToolbar = ({ return ( <> + {disabledMcpServerIds !== undefined && onDisabledMcpServerIdsChange !== undefined && ( + <> + + + + )} { + return ( +
+
+ +

Extra Features

+
+
+ Add MCP servers, include files and more. +
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index 9394e62d0..3e16bb5aa 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -7,10 +7,10 @@ import { CustomSlateEditor } from '@/features/chat/customSlateEditor'; import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types'; import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils'; import { useChat } from '@ai-sdk/react'; -import { CreateUIMessage, DefaultChatTransport } from 'ai'; +import { CreateUIMessage, DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'; import { ArrowDownIcon, CopyIcon } from 'lucide-react'; import { useNavigationGuard } from 'next-navigation-guard'; -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useStickToBottom } from 'use-stick-to-bottom'; import { Descendant } from 'slate'; import { useMessagePairs } from '../../useMessagePairs'; @@ -19,12 +19,15 @@ import { ChatBox } from '../chatBox'; import { ChatBoxToolbar } from '../chatBox/chatBoxToolbar'; import { ChatThreadListItem } from './chatThreadListItem'; import { ErrorBanner } from './errorBanner'; +import { McpFailedServersBanner } from './mcpFailedServersBanner'; import { useRouter } from 'next/navigation'; import { usePrevious } from '@uidotdev/usehooks'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { duplicateChat, generateAndUpdateChatNameFromMessage } from '../../actions'; import { isServiceError } from '@/lib/utils'; import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; +import { McpServerIconContext, McpServerIconMap } from '../../mcpServerIconContext'; +import { ToolApprovalProvider } from '../../toolApprovalContext'; import useCaptureEvent from '@/hooks/useCaptureEvent'; import { SignInPromptBanner } from './signInPromptBanner'; import { DuplicateChatDialog } from '@/app/(app)/chat/components/duplicateChatDialog'; @@ -42,6 +45,8 @@ interface ChatThreadProps { searchContexts: SearchContextQuery[]; selectedSearchScopes: SearchScope[]; onSelectedSearchScopesChange: (items: SearchScope[]) => void; + disabledMcpServerIds: string[]; + onDisabledMcpServerIdsChange: (ids: string[]) => void; isOwner?: boolean; isAuthenticated: boolean; isLoginWallEnabled: boolean; @@ -57,6 +62,8 @@ export const ChatThread = ({ searchContexts, selectedSearchScopes, onSelectedSearchScopesChange, + disabledMcpServerIds, + onDisabledMcpServerIdsChange, isOwner = true, isAuthenticated, isLoginWallEnabled, @@ -80,13 +87,66 @@ export const ChatThread = ({ ) ?? [] ); + const [mcpServerIconMap, setMcpServerIconMap] = useState(() => { + const map: McpServerIconMap = {}; + initialMessages?.forEach((message) => { + message.parts + .filter((part) => part.type === 'data-mcp-server') + .forEach((part) => { + map[part.data.sanitizedName] = part.data.faviconUrl; + }); + }); + return map; + }); + + const [failedMcpServers, setFailedMcpServers] = useState(() => { + const names: string[] = []; + initialMessages?.forEach((message) => { + message.parts + .filter((part) => part.type === 'data-mcp-failed-server') + .forEach((part) => { + if (!names.includes(part.data.serverName)) { + names.push(part.data.serverName); + } + }); + }); + return names; + }); + const [isFailedMcpBannerVisible, setIsFailedMcpBannerVisible] = useState(false); + const { selectedLanguageModel } = useSelectedLanguageModel({ languageModels, }); + // Refs to capture the latest request params for the transport body. + // The transport is created once (useMemo) but params change over time, + // so refs ensure the dynamic body function always reads current values. + const searchScopesRef = useRef(selectedSearchScopes); + const modelRef = useRef(selectedLanguageModel); + const disabledMcpRef = useRef(disabledMcpServerIds); + + useEffect(() => { searchScopesRef.current = selectedSearchScopes; }, [selectedSearchScopes]); + useEffect(() => { modelRef.current = selectedLanguageModel; }, [selectedLanguageModel]); + useEffect(() => { disabledMcpRef.current = disabledMcpServerIds; }, [disabledMcpServerIds]); + + // Transport with dynamic body — resolved on every request (including auto-resends + // triggered by sendAutomaticallyWhen after tool approval). + const transport = useMemo(() => new DefaultChatTransport({ + api: '/api/chat', + headers: { + 'X-Sourcebot-Client-Source': 'sourcebot-web-client', + }, + body: () => ({ + selectedSearchScopes: searchScopesRef.current, + languageModel: modelRef.current, + disabledMcpServerIds: disabledMcpRef.current, + }), + }), []); + const { messages, sendMessage: _sendMessage, + addToolApprovalResponse, error, status, stop, @@ -94,17 +154,28 @@ export const ChatThread = ({ } = useChat({ id: defaultChatId, messages: initialMessages, - transport: new DefaultChatTransport({ - api: '/api/chat', - headers: { - 'X-Sourcebot-Client-Source': 'sourcebot-web-client', - }, - }), + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, onData: (dataPart) => { // Keeps sources added by the assistant in sync. if (dataPart.type === 'data-source') { setSources((prev) => [...prev, dataPart.data]); } + if (dataPart.type === 'data-mcp-server') { + setMcpServerIconMap((prev) => ({ + ...prev, + [dataPart.data.sanitizedName]: dataPart.data.faviconUrl, + })); + } + if (dataPart.type === 'data-mcp-failed-server') { + setFailedMcpServers((prev) => { + if (prev.includes(dataPart.data.serverName)) { + return prev; + } + return [...prev, dataPart.data.serverName]; + }); + setIsFailedMcpBannerVisible(true); + } } }); @@ -127,6 +198,7 @@ export const ChatThread = ({ body: { selectedSearchScopes, languageModel: selectedLanguageModel, + disabledMcpServerIds, } satisfies AdditionalChatRequestParams, }); @@ -156,6 +228,7 @@ export const ChatThread = ({ selectedLanguageModel, _sendMessage, selectedSearchScopes, + disabledMcpServerIds, messages.length, toast, chatId, @@ -270,13 +343,13 @@ export const ChatThread = ({ const text = slateContentToString(children); const mentions = getAllMentionElements(children); - const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); + const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes, disabledMcpServerIds); sendMessage(message); scrollToBottom(); resetEditor(editor); - }, [sendMessage, selectedSearchScopes, scrollToBottom]); + }, [sendMessage, selectedSearchScopes, disabledMcpServerIds, scrollToBottom]); const onDuplicate = useCallback(async (newName: string): Promise => { if (!defaultChatId) { @@ -298,7 +371,8 @@ export const ChatThread = ({ }, [defaultChatId, toast, router, captureEvent]); return ( - <> + + {error && ( setIsErrorBannerVisible(false)} /> )} + setIsFailedMcpBannerVisible(false)} + />
@@ -426,6 +507,7 @@ export const ChatThread = ({
)}
- + + ); } diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index 0cbd4b264..f56bd8f8b 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -6,11 +6,13 @@ import { Skeleton } from '@/components/ui/skeleton'; import { CheckCircle, Loader2 } from 'lucide-react'; import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import scrollIntoView from 'scroll-into-view-if-needed'; +import { DynamicToolUIPart } from "ai"; import { Reference, referenceSchema, SBChatMessage, Source } from "../../types"; import { useExtractReferences } from '../../useExtractReferences'; import { getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, tryResolveFileReference } from '../../utils'; import { AnswerCard } from './answerCard'; import { DetailsCard } from './detailsCard'; +import { ToolApprovalBanner } from './toolApprovalBanner'; import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer'; import { ReferencedSourcesListView } from './referencedSourcesListView'; import isEqual from "fast-deep-equal/react"; @@ -106,7 +108,8 @@ const ChatThreadListItemComponent = forwardRef { + if (!assistantMessage) { + return []; + } + return assistantMessage.parts.filter( + (part): part is DynamicToolUIPart => part.type === 'dynamic-tool' && part.state === 'approval-requested' + ); + }, [assistantMessage]); + // Auto-collapse when answer first appears, but only once and respect user preference useEffect(() => { @@ -364,6 +377,10 @@ const ChatThreadListItemComponent = forwardRef + {approvalRequestedParts.length > 0 && ( + + )} + {(answerPart && assistantMessage) ? ( - ) : !isStreaming && ( + ) : !isStreaming && approvalRequestedParts.length === 0 && (

Error: No answer response was provided

)}
diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 0e2365ea6..5997df6e7 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -25,6 +25,8 @@ import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListTreeToolComponent } from './tools/listTreeToolComponent'; import { ReadFileToolComponent } from './tools/readFileToolComponent'; import { ToolOutputGuard } from './tools/toolOutputGuard'; +import { McpToolComponent } from './tools/mcpToolComponent'; +import { ToolSearchToolComponent } from './tools/toolSearchToolComponent'; interface DetailsCardProps { @@ -48,7 +50,10 @@ const DetailsCardComponent = ({ }: DetailsCardProps) => { const captureEvent = useCaptureEvent(); - const toolCallCount = useMemo(() => thinkingSteps.flat().filter(part => part.type.startsWith('tool-')).length, [thinkingSteps]); + const toolCallCount = useMemo(() => thinkingSteps.flat().filter(part => + part.type.startsWith('tool-') || + (part.type === 'dynamic-tool' && part.toolName.startsWith('mcp_')) + ).length, [thinkingSteps]); const handleExpandedChanged = useCallback((next: boolean) => { captureEvent('wa_chat_details_card_toggled', { chatId, isExpanded: next }); @@ -308,8 +313,19 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { {(output) => } ) - case 'data-source': + case 'tool-tool_request_activation': + if (part.state !== 'output-available') { + return Activating tool...; + } + return ; case 'dynamic-tool': + if (part.toolName.startsWith('mcp_')) { + return ; + } + return null; + case 'data-source': + case 'data-mcp-server': + case 'data-mcp-failed-server': case 'file': case 'source-document': case 'source-url': diff --git a/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx new file mode 100644 index 000000000..0c74fe72f --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { AlertTriangle, X } from 'lucide-react'; + +interface McpFailedServersBannerProps { + serverNames: string[]; + isVisible: boolean; + onClose: () => void; +} + +export const McpFailedServersBanner = ({ serverNames, isVisible, onClose }: McpFailedServersBannerProps) => { + if (!isVisible || serverNames.length === 0) { + return null; + } + + const message = serverNames.length === 1 + ? `MCP server "${serverNames[0]}" failed to load tools` + : `${serverNames.length} MCP servers failed to load tools`; + + return ( +
+
+
+
+ + + {message} + +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx b/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx new file mode 100644 index 000000000..0724c93b7 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { useMcpServerIconMap } from "@/features/chat/mcpServerIconContext"; +import { useToolApproval } from "@/features/chat/toolApprovalContext"; +import { cn } from "@/lib/utils"; +import { DynamicToolUIPart } from "ai"; +import { ChevronRight } from "lucide-react"; +import { useCallback, useState } from "react"; +import { parseMcpToolName } from "./tools/mcpToolComponent"; +import { JsonHighlighter } from "./tools/jsonHighlighter"; + +interface ToolApprovalBannerProps { + parts: DynamicToolUIPart[]; +} + +export const ToolApprovalBanner = ({ parts }: ToolApprovalBannerProps) => { + const addToolApprovalResponse = useToolApproval(); + const iconMap = useMcpServerIconMap(); + + if (parts.length === 0) { + return null; + } + + return ( +
+ {parts.map((part) => ( + + ))} +
+ ); +}; + +const ToolApprovalItem = ({ + part, + addToolApprovalResponse, + iconMap, +}: { + part: DynamicToolUIPart; + addToolApprovalResponse: ReturnType; + iconMap: Record; +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const parsed = parseMcpToolName(part.toolName); + const serverName = parsed?.serverName ?? part.toolName; + const toolName = parsed?.toolName ?? part.toolName; + const faviconUrl = parsed ? iconMap[parsed.serverName] : undefined; + + const hasInput = part.state !== 'input-streaming'; + const requestText = hasInput ? JSON.stringify(part.input, null, 2) : ''; + + const onToggle = useCallback(() => setIsExpanded(v => !v), []); + + const onApprove = useCallback(() => { + if (part.state === 'approval-requested' && addToolApprovalResponse) { + addToolApprovalResponse({ id: part.approval.id, approved: true }); + } + }, [part, addToolApprovalResponse]); + + const onDeny = useCallback(() => { + if (part.state === 'approval-requested' && addToolApprovalResponse) { + addToolApprovalResponse({ id: part.approval.id, approved: false, reason: 'User denied' }); + } + }, [part, addToolApprovalResponse]); + + return ( +
+
+ +
+ + +
+
+ {hasInput && isExpanded && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/jsonHighlighter.tsx b/packages/web/src/features/chat/components/chatThread/tools/jsonHighlighter.tsx new file mode 100644 index 000000000..18203a9de --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/jsonHighlighter.tsx @@ -0,0 +1,151 @@ +'use client'; + +export function unescapeJsonStrings(value: unknown): unknown { + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return unescapeJsonStrings(parsed); + } + } catch { + // not JSON — leave as-is + } + return value; + } + if (Array.isArray(value)) { + return value.map(unescapeJsonStrings); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, unescapeJsonStrings(v)]) + ); + } + return value; +} + +type TokenType = 'key' | 'string' | 'number' | 'boolean' | 'null' | 'structural' | 'whitespace' | 'other'; + +interface Token { + type: TokenType; + value: string; +} + +function tokenizeJson(text: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + + // Whitespace + if (/\s/.test(ch)) { + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) { + j++; + } + tokens.push({ type: 'whitespace', value: text.slice(i, j) }); + i = j; + continue; + } + + // String + if (ch === '"') { + let j = i + 1; + while (j < text.length) { + if (text[j] === '\\') { + j += 2; + } else if (text[j] === '"') { + j++; + break; + } else { + j++; + } + } + const str = text.slice(i, j); + + // Lookahead past whitespace for a colon → this is a key + let k = j; + while (k < text.length && /\s/.test(text[k])) { + k++; + } + const isKey = text[k] === ':'; + + tokens.push({ type: isKey ? 'key' : 'string', value: str }); + i = j; + continue; + } + + // Number + if (ch === '-' || /\d/.test(ch)) { + const match = text.slice(i).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (match) { + tokens.push({ type: 'number', value: match[0] }); + i += match[0].length; + continue; + } + } + + // Boolean / null keywords + if (text.slice(i, i + 4) === 'true') { + tokens.push({ type: 'boolean', value: 'true' }); + i += 4; + continue; + } + if (text.slice(i, i + 5) === 'false') { + tokens.push({ type: 'boolean', value: 'false' }); + i += 5; + continue; + } + if (text.slice(i, i + 4) === 'null') { + tokens.push({ type: 'null', value: 'null' }); + i += 4; + continue; + } + + // Structural characters + if ('{}[]:,'.includes(ch)) { + tokens.push({ type: 'structural', value: ch }); + i++; + continue; + } + + // Fallback + tokens.push({ type: 'other', value: ch }); + i++; + } + + return tokens; +} + +const TOKEN_CLASSES: Record = { + key: 'text-editor-tag-name', + string: 'text-editor-tag-string', + number: 'text-editor-tag-number', + boolean: 'text-editor-tag-atom', + null: 'text-editor-tag-atom', + structural: 'text-muted-foreground', + whitespace: '', + other: '', +}; + +import { useMemo } from "react"; + +export const JsonHighlighter = ({ text }: { text: string }) => { + const tokens = useMemo(() => tokenizeJson(text), [text]); + + return ( +
+            {tokens.map((token, i) => {
+                const cls = TOKEN_CLASSES[token.type];
+                if (!cls) {
+                    return token.value;
+                }
+                return (
+                    
+                        {token.value}
+                    
+                );
+            })}
+        
+ ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx new file mode 100644 index 000000000..3e679a21b --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { useMcpServerIconMap } from "@/features/chat/mcpServerIconContext"; +import { cn } from "@/lib/utils"; +import { DynamicToolUIPart } from "ai"; +import { CheckCircle, ChevronDown, XCircle } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { JsonHighlighter, unescapeJsonStrings } from "./jsonHighlighter"; + +export function parseMcpToolName(toolName: string): { serverName: string; toolName: string } | null { + if (!toolName.startsWith('mcp_')) { + return null; + } + const withoutPrefix = toolName.slice(4); + const doubleUnderscoreIdx = withoutPrefix.indexOf('__'); + if (doubleUnderscoreIdx === -1) { + return null; + } + return { + serverName: withoutPrefix.slice(0, doubleUnderscoreIdx), + toolName: withoutPrefix.slice(doubleUnderscoreIdx + 2), + }; +} + +export const McpToolComponent = ({ part }: { part: DynamicToolUIPart }) => { + const needsApproval = part.state === 'approval-requested'; + const [isExpanded, setIsExpanded] = useState(needsApproval); + const onToggle = useCallback(() => setIsExpanded(v => !v), []); + + const iconMap = useMcpServerIconMap(); + const parsed = parseMcpToolName(part.toolName); + const displayName = parsed + ? `${parsed.serverName}: ${parsed.toolName}` + : part.toolName; + const faviconUrl = parsed ? iconMap[parsed.serverName] : undefined; + + const hasInput = part.state !== 'input-streaming'; + + const requestText = useMemo( + () => hasInput ? JSON.stringify(part.input, null, 2) : '', + [hasInput, part.input] + ); + const responseText = useMemo(() => { + if (part.state === 'output-available') { + try { + return JSON.stringify(unescapeJsonStrings(part.output), null, 2); + } catch { + return String(part.output); + } + } + if (part.state === 'output-error') { + return part.errorText ?? ''; + } + return undefined; + }, [part.state, part.output, part.errorText]); + + const onCopyRequest = useCallback(() => { + navigator.clipboard.writeText(requestText); + return true; + }, [requestText]); + + const onCopyResponse = useCallback(() => { + if (!responseText) { + return false; + } + navigator.clipboard.writeText(responseText); + return true; + }, [responseText]); + + const renderStatus = () => { + if (part.state === 'output-error') { + return ( + + + {displayName} failed: {part.errorText} + + ); + } + if (part.state === 'output-denied') { + return ( + + + + {displayName} — denied + + ); + } + if (part.state === 'approval-requested') { + return ( + + + {displayName} + + ); + } + if (part.state === 'approval-responded') { + const approved = part.approval.approved; + return ( + + + {approved ? : } + {displayName}{approved ? '...' : ' — denied'} + + ); + } + if (part.state === 'output-available') { + return ( + + + {displayName} + + ); + } + // input-streaming, input-available, or other in-progress states + return ( + + + {displayName}... + + ); + }; + + return ( +
+
+
+ {renderStatus()} +
+ {hasInput && ( + + )} +
+ {hasInput && isExpanded && ( +
+ + + + {responseText !== undefined && ( + <> +
+ +
+ +
+
+ + )} +
+ )} +
+ ); +}; + + +const ResultSection = ({ label, onCopy, children }: { label: string; onCopy: () => boolean; children: React.ReactNode }) => ( +
+
+ {label} + +
+
+ {children} +
+
+); diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx index aac756f4a..43ce2021d 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx @@ -6,6 +6,7 @@ import { ToolUIPart } from "ai"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCallback, useState } from "react"; +import { JsonHighlighter, unescapeJsonStrings } from "./jsonHighlighter"; export const ToolOutputGuard = >({ part, @@ -27,7 +28,7 @@ export const ToolOutputGuard = { const raw = (part.output as { output: string }).output; try { - return JSON.stringify(JSON.parse(raw), null, 2); + return JSON.stringify(unescapeJsonStrings(JSON.parse(raw)), null, 2); } catch { return raw; } @@ -70,17 +71,15 @@ export const ToolOutputGuard = -
-                            {requestText}
-                        
+
{responseText !== undefined && ( <>
-
-                                    {responseText}
-                                
+
+ +
)} diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx new file mode 100644 index 000000000..3711e22bd --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Separator } from "@/components/ui/separator"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronRight } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ToolSearchResult { + name: string; + description: string; +} + +interface ToolSearchToolComponentProps { + query: string; + results: ToolSearchResult[]; +} + +export const ToolSearchToolComponent = ({ query, results }: ToolSearchToolComponentProps) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + +
+ + Searched MCP tools: {query} + + {results.length} result{results.length === 1 ? '' : 's'} + +
+
+ +
+ {results.map((result) => ( +
+ {result.name} + {result.description && ( + <> + - + {result.description} + + )} +
+ ))} + {results.length === 0 && ( + No tools found + )} +
+
+
+ ); +}; diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index 1038852c6..47e3f58cf 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -10,3 +10,4 @@ export const ANSWER_TAG = ''; export const SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY = 'selectedSearchScopes'; export const SET_CHAT_STATE_SESSION_STORAGE_KEY = 'setChatState'; export const PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY = 'pendingChatSubmission'; +export const DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY = 'disabledMcpServerIds'; diff --git a/packages/web/src/features/chat/mcpServerIconContext.tsx b/packages/web/src/features/chat/mcpServerIconContext.tsx new file mode 100644 index 000000000..94628f4a5 --- /dev/null +++ b/packages/web/src/features/chat/mcpServerIconContext.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +// Maps sanitized server name (e.g. "linear") to a favicon URL. +export type McpServerIconMap = Record; + +export const McpServerIconContext = createContext({}); + +export const useMcpServerIconMap = () => useContext(McpServerIconContext); diff --git a/packages/web/src/features/chat/toolApprovalContext.tsx b/packages/web/src/features/chat/toolApprovalContext.tsx new file mode 100644 index 000000000..d4379c394 --- /dev/null +++ b/packages/web/src/features/chat/toolApprovalContext.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ChatAddToolApproveResponseFunction } from 'ai'; + +const ToolApprovalContext = createContext(null); + +export const ToolApprovalProvider = ToolApprovalContext.Provider; +export const useToolApproval = () => useContext(ToolApprovalContext); \ No newline at end of file diff --git a/packages/web/src/features/chat/types.test.ts b/packages/web/src/features/chat/types.test.ts new file mode 100644 index 000000000..a9f41df7c --- /dev/null +++ b/packages/web/src/features/chat/types.test.ts @@ -0,0 +1,72 @@ +import { expect, test, describe } from 'vitest'; +import { sbChatMessageMetadataSchema, additionalChatRequestParamsSchema } from './types'; + +describe('sbChatMessageMetadataSchema', () => { + test('accepts disabledMcpServerIds as array of strings', () => { + const result = sbChatMessageMetadataSchema.safeParse({ + disabledMcpServerIds: ['id1', 'id2'], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.disabledMcpServerIds).toEqual(['id1', 'id2']); + } + }); + + test('accepts missing disabledMcpServerIds (optional)', () => { + const result = sbChatMessageMetadataSchema.safeParse({}); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.disabledMcpServerIds).toBeUndefined(); + } + }); + + test('rejects non-string array values', () => { + const result = sbChatMessageMetadataSchema.safeParse({ + disabledMcpServerIds: [123, 456], + }); + + expect(result.success).toBe(false); + }); +}); + +describe('additionalChatRequestParamsSchema', () => { + const validBase = { + languageModel: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + }, + selectedSearchScopes: [], + }; + + test('defaults disabledMcpServerIds to empty array', () => { + const result = additionalChatRequestParamsSchema.safeParse(validBase); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.disabledMcpServerIds).toEqual([]); + } + }); + + test('accepts explicit disabledMcpServerIds array', () => { + const result = additionalChatRequestParamsSchema.safeParse({ + ...validBase, + disabledMcpServerIds: ['abc'], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.disabledMcpServerIds).toEqual(['abc']); + } + }); + + test('rejects non-array value for disabledMcpServerIds', () => { + const result = additionalChatRequestParamsSchema.safeParse({ + ...validBase, + disabledMcpServerIds: 'not-an-array', + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 6e990f5c2..3c2619f14 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -60,6 +60,7 @@ export const sbChatMessageMetadataSchema = z.object({ userId: z.string().optional(), })).optional(), selectedSearchScopes: z.array(searchScopeSchema).optional(), + disabledMcpServerIds: z.array(z.string()).optional(), traceId: z.string().optional(), }); @@ -67,12 +68,22 @@ export type SBChatMessageMetadata = z.infer; export type SBChatMessageToolTypes = { [K in keyof ReturnType]: InferUITool[K]>; +} & { + tool_request_activation: { + input: { tool_to_activate_name: string }; + output: { results: Array<{ name: string; description: string }> }; + }; }; export type SBChatMessageDataParts = { // The `source` data type allows us to know what sources the LLM saw // during retrieval. "source": Source, + // The `mcp-server` data type carries favicon metadata for connected MCP servers, + // keyed by sanitized server name (e.g. "linear"). + "mcp-server": { sanitizedName: string; faviconUrl: string }, + // The `mcp-failed-server` data type surfaces MCP servers that failed to load their tools. + "mcp-failed-server": { serverName: string }, } export type SBChatMessage = UIMessage< @@ -143,6 +154,7 @@ declare module 'slate' { export type SetChatStatePayload = { inputMessage: CreateUIMessage; selectedSearchScopes: SearchScope[]; + disabledMcpServerIds: string[]; } @@ -188,5 +200,6 @@ export type LanguageModelInfo = { export const additionalChatRequestParamsSchema = z.object({ languageModel: languageModelInfoSchema, selectedSearchScopes: z.array(searchScopeSchema), + disabledMcpServerIds: z.array(z.string()).default([]), }); -export type AdditionalChatRequestParams = z.infer; \ No newline at end of file +export type AdditionalChatRequestParams = z.infer; diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts index 18a5a58b9..f030f186d 100644 --- a/packages/web/src/features/chat/useCreateNewChatThread.ts +++ b/packages/web/src/features/chat/useCreateNewChatThread.ts @@ -10,7 +10,7 @@ import { createChat } from "./actions"; import { isServiceError } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils"; import { SearchScope, SetChatStatePayload } from "./types"; -import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; +import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; import { useSessionStorage } from "usehooks-ts"; export const useCreateNewChatThread = () => { @@ -19,19 +19,29 @@ export const useCreateNewChatThread = () => { const router = useRouter(); const [, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null); - const createNewChatThread = useCallback(async (children: Descendant[], overrideSearchScopes?: SearchScope[]) => { + const createNewChatThread = useCallback(async (children: Descendant[], overrideSearchScopes?: SearchScope[], overrideDisabledMcpServerIds?: string[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); let storedScopes: SearchScope[] = []; try { const stored = window.localStorage.getItem(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY); - if (stored) storedScopes = JSON.parse(stored) as SearchScope[]; + if (stored) { + storedScopes = JSON.parse(stored) as SearchScope[]; + } } catch { /* fall through to [] */ } - const selectedSearchScopes = overrideSearchScopes ?? storedScopes; + let storedDisabledMcpServerIds: string[] = []; + try { + const stored = window.localStorage.getItem(DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY); + if (stored) { + storedDisabledMcpServerIds = JSON.parse(stored) as string[]; + } + } catch { /* fall through to [] */ } - const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes); + const selectedSearchScopes = overrideSearchScopes ?? storedScopes; + const disabledMcpServerIds = overrideDisabledMcpServerIds ?? storedDisabledMcpServerIds; + const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes, disabledMcpServerIds); setIsLoading(true); const response = await createChat({ source: 'sourcebot-web-client' }); @@ -46,6 +56,7 @@ export const useCreateNewChatThread = () => { setChatState({ inputMessage, selectedSearchScopes, + disabledMcpServerIds, }); const url = createPathWithQueryParams(`/chat/${response.id}`); diff --git a/packages/web/src/features/chat/utils.test.ts b/packages/web/src/features/chat/utils.test.ts index 26359d2a9..e5a89c0bb 100644 --- a/packages/web/src/features/chat/utils.test.ts +++ b/packages/web/src/features/chat/utils.test.ts @@ -1,5 +1,5 @@ -import { expect, test, vi } from 'vitest' -import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils' +import { expect, test, describe, vi } from 'vitest' +import { createUIMessage, fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils' import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants'; import { SBChatMessage, SBChatMessagePart } from './types'; @@ -351,3 +351,31 @@ test('repairReferences handles malformed inline code blocks', () => { const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.'; expect(repairReferences(input)).toBe(expected); }); + +describe('createUIMessage', () => { + test('includes disabledMcpServerIds in metadata when provided', () => { + const result = createUIMessage('hello', [], [], ['server1', 'server2']); + + expect(result.metadata?.disabledMcpServerIds).toEqual(['server1', 'server2']); + }); + + test('defaults disabledMcpServerIds to empty array when omitted', () => { + const result = createUIMessage('hello', [], []); + + expect(result.metadata?.disabledMcpServerIds).toEqual([]); + }); + + test('passes through empty array', () => { + const result = createUIMessage('hello', [], [], []); + + expect(result.metadata?.disabledMcpServerIds).toEqual([]); + }); + + test('includes both selectedSearchScopes and disabledMcpServerIds in metadata', () => { + const scopes = [{ type: 'repo' as const, value: 'org/repo', name: 'repo', codeHostType: 'github' }]; + const result = createUIMessage('hello', [], scopes, ['disabled1']); + + expect(result.metadata?.selectedSearchScopes).toEqual(scopes); + expect(result.metadata?.disabledMcpServerIds).toEqual(['disabled1']); + }); +}); diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index 38dd784fd..2ecccd727 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -176,7 +176,7 @@ export const addLineNumbers = (source: string, lineOffset = 1) => { return source.split('\n').map((line, index) => `${index + lineOffset}: ${line}`).join('\n'); } -export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[]): CreateUIMessage => { +export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[], disabledMcpServerIds: string[] = []): CreateUIMessage => { // Converts applicable mentions into sources. const sources: Source[] = mentions .map((mention) => { @@ -209,6 +209,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedS ], metadata: { selectedSearchScopes, + disabledMcpServerIds, }, } } diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts new file mode 100644 index 000000000..0e0b89819 --- /dev/null +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -0,0 +1,168 @@ +import 'server-only'; +import type { + OAuthClientProvider, + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from '@ai-sdk/mcp'; +// Note: We use the raw (unscoped) prisma client here intentionally. The user-scoped +// prisma extension only filters Repo queries, and all MCP queries in this file already +// filter explicitly by userId and/or serverId, so scoping would be a no-op. +import { __unsafePrisma } from '@/prisma'; +import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; + +/** + * Prisma-backed OAuthClientProvider for connecting to external MCP servers. + * + * Stores dynamic client registration (client_id/secret) on McpServer (per-org), + * and per-user tokens + ephemeral PKCE state on McpServerCredential. + */ +export class PrismaOAuthClientProvider implements OAuthClientProvider { + constructor( + private readonly serverId: string, + private readonly userId: string, + private readonly callbackUrl: string, + ) {} + + /** Populated by redirectToAuthorization — read after auth() returns 'REDIRECT'. */ + public authorizationUrl: string | undefined; + + get redirectUrl(): string | URL { + return this.callbackUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [this.callbackUrl], + client_name: 'Sourcebot', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + } + + async clientInformation(): Promise { + const server = await __unsafePrisma.mcpServer.findUnique({ + where: { id: this.serverId }, + select: { clientInfo: true }, + }); + if (!server?.clientInfo) return undefined; + + const decrypted = decryptOAuthToken(server.clientInfo); + return decrypted ? JSON.parse(decrypted) : undefined; + } + + async saveClientInformation(info: OAuthClientInformation): Promise { + const encrypted = encryptOAuthToken(JSON.stringify(info)); + await __unsafePrisma.mcpServer.update({ + where: { id: this.serverId }, + data: { clientInfo: encrypted }, + }); + } + + async tokens(): Promise { + const cred = await this.getOrCreateCredential(); + if (!cred.tokens) return undefined; + + const decrypted = decryptOAuthToken(cred.tokens); + return decrypted ? JSON.parse(decrypted) : undefined; + } + + async saveTokens(tokens: OAuthTokens): Promise { + const encrypted = encryptOAuthToken(JSON.stringify(tokens)); + const tokensExpiresAt = tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null; + await __unsafePrisma.mcpServerCredential.update({ + where: { userId_serverId: { userId: this.userId, serverId: this.serverId } }, + data: { tokens: encrypted, tokensExpiresAt }, + }); + } + + async codeVerifier(): Promise { + const cred = await this.getOrCreateCredential(); + if (!cred.codeVerifier) { + throw new Error('No code verifier found'); + } + return cred.codeVerifier; + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await this.upsertCredential({ codeVerifier }); + } + + async state(): Promise { + return crypto.randomUUID(); + } + + async saveState(state: string): Promise { + await this.upsertCredential({ state }); + } + + async storedState(): Promise { + const cred = await this.getOrCreateCredential(); + return cred.state ?? undefined; + } + + async redirectToAuthorization(url: URL): Promise { + // Force the OAuth provider to show a consent/login screen on every authorization. + // This prevents a stolen-session attack where an attacker signs into Sourcebot on + // a victim's machine and silently obtains the victim's provider tokens via an + // existing browser session. + if (!url.searchParams.has('prompt')) { + url.searchParams.set('prompt', 'consent'); + } + + // Clear any stale tokens from the database. This is called when the SDK determines + // that existing tokens are no longer valid (e.g., the access token expired and the + // refresh token was revoked). Clearing them ensures the UI reflects "not connected" + // so the user knows to re-authenticate, rather than staying stuck in a state where + // the server appears connected but all tool calls fail. + await this.invalidateCredentials('tokens'); + + this.authorizationUrl = url.toString(); + } + + async invalidateCredentials( + scope: 'all' | 'client' | 'tokens' | 'verifier', + ): Promise { + if (scope === 'all' || scope === 'client') { + await __unsafePrisma.mcpServer.update({ + where: { id: this.serverId }, + data: { clientInfo: null }, + }); + } + + if (scope === 'all' || scope === 'tokens') { + await this.upsertCredential({ tokens: null }); + } + + if (scope === 'all' || scope === 'verifier') { + await this.upsertCredential({ codeVerifier: null, state: null }); + } + } + + private async getOrCreateCredential() { + return __unsafePrisma.mcpServerCredential.upsert({ + where: { + userId_serverId: { userId: this.userId, serverId: this.serverId }, + }, + create: { userId: this.userId, serverId: this.serverId }, + update: {}, + }); + } + + private async upsertCredential(data: { + tokens?: string | null; + codeVerifier?: string | null; + state?: string | null; + }) { + await __unsafePrisma.mcpServerCredential.upsert({ + where: { + userId_serverId: { userId: this.userId, serverId: this.serverId }, + }, + create: { userId: this.userId, serverId: this.serverId, ...data }, + update: data, + }); + } +} \ No newline at end of file diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 714932c30..fdb09d67d 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -35,4 +35,6 @@ export enum ErrorCode { LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED', LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED', API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED', + MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS', + MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND', } diff --git a/yarn.lock b/yarn.lock index 2357fe36c..18e92f632 100644 --- a/yarn.lock +++ b/yarn.lock @@ -99,6 +99,19 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/mcp@npm:^2.0.0-beta.11": + version: 2.0.0-beta.11 + resolution: "@ai-sdk/mcp@npm:2.0.0-beta.11" + dependencies: + "@ai-sdk/provider": "npm:4.0.0-beta.5" + "@ai-sdk/provider-utils": "npm:5.0.0-beta.7" + pkce-challenge: "npm:^5.0.0" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/efcc9b9f5f8b20b78b2d0ee6d83b34466b2ec456c3b40b5b8b10af226e7d3f6144f964d87a20c5fc54c24b21f3610cb75cc246c30833b99fb501438a206c9933 + languageName: node + linkType: hard + "@ai-sdk/mistral@npm:^3.0.30": version: 3.0.30 resolution: "@ai-sdk/mistral@npm:3.0.30" @@ -148,6 +161,19 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/provider-utils@npm:5.0.0-beta.7": + version: 5.0.0-beta.7 + resolution: "@ai-sdk/provider-utils@npm:5.0.0-beta.7" + dependencies: + "@ai-sdk/provider": "npm:4.0.0-beta.5" + "@standard-schema/spec": "npm:^1.1.0" + eventsource-parser: "npm:^3.0.6" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/440825f7b599da6a0bd830c905f9ba4f21defcf7068bc98154ea38158c1134b049cb2815047013668f48b679a23de1d3c19eb072a65115dc860070168104c99e + languageName: node + linkType: hard + "@ai-sdk/provider@npm:3.0.8": version: 3.0.8 resolution: "@ai-sdk/provider@npm:3.0.8" @@ -157,6 +183,15 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/provider@npm:4.0.0-beta.5": + version: 4.0.0-beta.5 + resolution: "@ai-sdk/provider@npm:4.0.0-beta.5" + dependencies: + json-schema: "npm:^0.4.0" + checksum: 10c0/886f5892268cc3425130c9b019a9eb1e2acdb5efd05d920b05d1ac1ab49603393d8e509e6e0a3c46dee533a411a51a2af2c6fa0a173b41130f5175a615add7fb + languageName: node + linkType: hard + "@ai-sdk/react@npm:^3.0.169": version: 3.0.169 resolution: "@ai-sdk/react@npm:3.0.169" @@ -9340,6 +9375,7 @@ __metadata: "@ai-sdk/deepseek": "npm:^2.0.29" "@ai-sdk/google": "npm:^3.0.64" "@ai-sdk/google-vertex": "npm:^4.0.111" + "@ai-sdk/mcp": "npm:^2.0.0-beta.11" "@ai-sdk/mistral": "npm:^3.0.30" "@ai-sdk/openai": "npm:^3.0.53" "@ai-sdk/openai-compatible": "npm:^2.0.41" @@ -9552,7 +9588,7 @@ __metadata: vitest: "npm:^4.1.4" vitest-mock-extended: "npm:^4.0.0" vscode-icons-js: "npm:^11.6.1" - zod: "npm:^3.25.74" + zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.24.5" languageName: unknown linkType: soft @@ -18957,13 +18993,20 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4": version: 4.0.4 resolution: "picomatch@npm:4.0.4" checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "picospinner@npm:^3.0.0": version: 3.0.0 resolution: "picospinner@npm:3.0.0" @@ -23537,7 +23580,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.0": +"zod@npm:^3.25.0, zod@npm:^3.25.76": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c From 48b4448355bb2f788433acbf4113e3d98ef807de Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 21 May 2026 18:57:45 -0700 Subject: [PATCH 02/40] Merge MCP user server credentials --- .../migration.sql | 14 ++--- .../migration.sql | 2 - .../migrations/20260326230727_/migration.sql | 24 -------- .../migration.sql | 2 - packages/db/prisma/schema.prisma | 23 +------- .../api/(server)/ee/askmcp/callback/route.ts | 27 ++++----- .../api/(server)/ee/askmcp/connect/route.ts | 3 +- .../api/(server)/ee/askmcp/servers/route.ts | 24 ++++---- packages/web/src/ee/features/mcp/actions.ts | 26 ++++----- .../ee/features/mcp/mcpClientFactory.test.ts | 37 +++++++------ .../src/ee/features/mcp/mcpClientFactory.ts | 41 +++++++------- .../features/mcp/prismaOAuthClientProvider.ts | 55 +++++++++++-------- 12 files changed, 117 insertions(+), 161 deletions(-) delete mode 100644 packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql delete mode 100644 packages/db/prisma/migrations/20260326230727_/migration.sql delete mode 100644 packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql diff --git a/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql b/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql index 3d3d9966f..30e6d30f9 100644 --- a/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql +++ b/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql @@ -1,7 +1,6 @@ -- CreateTable CREATE TABLE "McpServer" ( "id" TEXT NOT NULL, - "name" TEXT NOT NULL, "serverUrl" TEXT NOT NULL, "clientInfo" TEXT, "orgId" INTEGER NOT NULL, @@ -12,30 +11,31 @@ CREATE TABLE "McpServer" ( ); -- CreateTable -CREATE TABLE "McpServerCredential" ( - "id" TEXT NOT NULL, +CREATE TABLE "UserMcpServer" ( "userId" TEXT NOT NULL, "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, "tokens" TEXT, + "tokensExpiresAt" TIMESTAMP(3), "codeVerifier" TEXT, "state" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "McpServerCredential_pkey" PRIMARY KEY ("id") + CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId") ); -- CreateIndex CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId"); -- CreateIndex -CREATE UNIQUE INDEX "McpServerCredential_userId_serverId_key" ON "McpServerCredential"("userId", "serverId"); +CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state"); -- AddForeignKey ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "McpServerCredential" ADD CONSTRAINT "McpServerCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "McpServerCredential" ADD CONSTRAINT "McpServerCredential_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql b/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql deleted file mode 100644 index d14625836..000000000 --- a/packages/db/prisma/migrations/20260325184501_add_mcp_server_credential_state_index/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- CreateIndex -CREATE INDEX "McpServerCredential_state_idx" ON "McpServerCredential"("state"); diff --git a/packages/db/prisma/migrations/20260326230727_/migration.sql b/packages/db/prisma/migrations/20260326230727_/migration.sql deleted file mode 100644 index b17ca3d7e..000000000 --- a/packages/db/prisma/migrations/20260326230727_/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `name` on the `McpServer` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "McpServer" DROP COLUMN "name"; - --- CreateTable -CREATE TABLE "UserMcpServer" ( - "userId" TEXT NOT NULL, - "serverId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId") -); - --- AddForeignKey -ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql b/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql deleted file mode 100644 index 26f316ab1..000000000 --- a/packages/db/prisma/migrations/20260327233318_add_tokens_expires_at/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "McpServerCredential" ADD COLUMN "tokensExpiresAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 42756b9fe..b55699878 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -410,7 +410,6 @@ model User { /// claim baked into the JWT cookie at mint time. sessionVersion Int @default(0) - mcpServerCredentials McpServerCredential[] userMcpServers UserMcpServer[] createdAt DateTime @default(now()) @@ -662,7 +661,6 @@ model McpServer { org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int - credentials McpServerCredential[] userMcpServers UserMcpServer[] createdAt DateTime @default(now()) @@ -671,7 +669,8 @@ model McpServer { @@unique([serverUrl, orgId]) } -/// Junction table: a user's personal reference to an MCP server with their chosen display name. +/// A user's personal connection to an MCP server. +/// Stores the user-chosen display name plus per-user OAuth tokens and ephemeral auth-flow state. model UserMcpServer { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @@ -681,22 +680,6 @@ model UserMcpServer { name String /// User-chosen display name (e.g., "Linear") - createdAt DateTime @default(now()) - - @@id([userId, serverId]) -} - -/// Per-user OAuth credentials for an external MCP server. -/// Stores tokens (long-lived) and ephemeral auth-flow state separately. -model McpServerCredential { - id String @id @default(cuid()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String - - server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) - serverId String - /// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens. tokens String? @@ -713,6 +696,6 @@ model McpServerCredential { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([userId, serverId]) + @@id([userId, serverId]) @@index([state]) } diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index bd340b9a0..ac5bea157 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -13,6 +13,7 @@ import { NextRequest, NextResponse } from 'next/server'; const logger = createLogger('mcp-oauth-callback'); +// eslint-disable-next-line authz/require-auth-wrapper -- OAuth redirect callback validates the active session with auth() and filters all queries by userId. export const GET = apiHandler(async (request: NextRequest) => { if (!(await hasEntitlement('oauth'))) { return Response.json( @@ -50,24 +51,24 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - const credential = await prisma.mcpServerCredential.findFirst({ + const userServer = await prisma.userMcpServer.findFirst({ where: { state, userId: session.user.id, }, - include: { + select: { + serverId: true, + name: true, server: { - include: { - userMcpServers: { - where: { userId: session.user.id }, - take: 1, - }, + select: { + orgId: true, + serverUrl: true, }, }, }, }); - if (!credential) { + if (!userServer) { return Response.json( { error: 'invalid_state', error_description: 'No pending authorization found for this state.' }, { status: 400 } @@ -77,7 +78,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const orgMembership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - orgId: credential.server.orgId, + orgId: userServer.server.orgId, userId: session.user.id, }, }, @@ -91,13 +92,13 @@ export const GET = apiHandler(async (request: NextRequest) => { } const provider = new PrismaOAuthClientProvider( - credential.serverId, + userServer.serverId, session.user.id, `${env.AUTH_URL}/api/ee/askmcp/callback`, ); const result = await mcpAuth(provider, { - serverUrl: new URL(credential.server.serverUrl), + serverUrl: new URL(userServer.server.serverUrl), authorizationCode: code, callbackState: state, }); @@ -108,7 +109,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); if (result === 'AUTHORIZED') { - const displayName = credential.server.userMcpServers[0]?.name ?? credential.server.serverUrl; + const displayName = userServer.name || userServer.server.serverUrl; logger.info(`Successfully authorized MCP server ${displayName} for user ${session.user.id}.`); settingsUrl.searchParams.set('status', 'connected'); settingsUrl.searchParams.set('server', displayName); @@ -119,4 +120,4 @@ export const GET = apiHandler(async (request: NextRequest) => { settingsUrl.searchParams.set('status', 'error'); settingsUrl.searchParams.set('message', 'Token exchange failed'); return NextResponse.redirect(settingsUrl); -}); \ No newline at end of file +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 8d0ff1b0e..7382409fe 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -45,6 +45,7 @@ export const POST = apiHandler(async (request: NextRequest) => { serverId: mcpServer.id, }, }, + select: { userId: true }, }); if (!userServer) { return notFound('MCP server not found'); @@ -74,4 +75,4 @@ export const POST = apiHandler(async (request: NextRequest) => { } return Response.json(result); -}); \ No newline at end of file +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index 8c922faba..fbefd686e 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -32,20 +32,20 @@ export const GET = apiHandler(async () => { const userServers = await prisma.userMcpServer.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, - include: { + select: { + name: true, + tokens: true, + tokensExpiresAt: true, server: { - include: { - credentials: { - where: { userId: user.id }, - take: 1, - }, + select: { + id: true, + serverUrl: true, }, }, }, }); return userServers.map((us): McpServerWithStatus => { - const credential = us.server.credentials[0] ?? null; const sanitizedName = sanitizeMcpServerName(us.name); const origin = new URL(us.server.serverUrl).origin; const faviconUrl = `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; @@ -53,14 +53,14 @@ export const GET = apiHandler(async () => { let isConnected = false; let isAuthExpired = false; - if (credential?.tokens) { + if (us.tokens) { try { - const decrypted = decryptOAuthToken(credential.tokens); + const decrypted = decryptOAuthToken(us.tokens); if (decrypted) { const tokens: OAuthTokens = JSON.parse(decrypted); - if (tokens.refresh_token || !credential.tokensExpiresAt) { + if (tokens.refresh_token || !us.tokensExpiresAt) { isConnected = true; - } else if (new Date() > credential.tokensExpiresAt) { + } else if (new Date() > us.tokensExpiresAt) { isAuthExpired = true; } else { isConnected = true; @@ -88,4 +88,4 @@ export const GET = apiHandler(async () => { } return Response.json(result); -}); \ No newline at end of file +}); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index f1b827bb8..1dfcb03ee 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -52,6 +52,7 @@ export const createMcpServer = async (name: string, serverUrl: string) => sew(() serverId: mcpServer.id, }, }, + select: { userId: true }, }); if (existingUserServer) { @@ -103,6 +104,7 @@ export const deleteMcpServer = async (serverId: string) => sew(() => serverId, }, }, + select: { userId: true }, }); if (!userServer) { @@ -113,24 +115,16 @@ export const deleteMcpServer = async (serverId: string) => sew(() => } satisfies ServiceError; } - // Delete the user's reference and their credentials. The McpServer row stays - // because other users may reference the same endpoint. - await prisma.$transaction([ - prisma.mcpServerCredential.deleteMany({ - where: { + // Delete the user's connection row. The McpServer row stays because other + // users may reference the same endpoint. + await prisma.userMcpServer.delete({ + where: { + userId_serverId: { userId: user.id, serverId, }, - }), - prisma.userMcpServer.delete({ - where: { - userId_serverId: { - userId: user.id, - serverId, - }, - }, - }), - ]); + }, + }); return { success: true }; - })); \ No newline at end of file + })); diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts index 69eefd6d1..9b6c1a0f6 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -41,23 +41,20 @@ const FUTURE = new Date('2099-01-01'); const TOKEN_NO_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer' }; const TOKEN_WITH_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer', refresh_token: 'ref' }; -function makeCredential(overrides: { +function makeUserServer(overrides: { tokens?: OAuthTokens; tokensExpiresAt?: Date | null; orgId?: number; - hasUserServer?: boolean; }) { return { serverId: 'srv-1', userId: 'user-1', + name: 'MyServer', tokens: JSON.stringify(overrides.tokens ?? TOKEN_NO_REFRESH), tokensExpiresAt: overrides.tokensExpiresAt ?? null, - codeVerifier: null, - state: null, server: { orgId: overrides.orgId ?? 1, serverUrl: 'https://example.com/mcp', - userMcpServers: overrides.hasUserServer === false ? [] : [{ name: 'MyServer' }], }, }; } @@ -86,8 +83,8 @@ describe('isTokenExpiredWithNoRefresh', () => { describe('getConnectedMcpClients', () => { test('skips server when access token expired and no refresh token', async () => { - prisma.mcpServerCredential.findMany.mockResolvedValue([ - makeCredential({ tokens: TOKEN_NO_REFRESH, tokensExpiresAt: PAST }), + prisma.userMcpServer.findMany.mockResolvedValue([ + makeUserServer({ tokens: TOKEN_NO_REFRESH, tokensExpiresAt: PAST }), ] as never); const result = await getConnectedMcpClients('user-1', 1); @@ -95,8 +92,8 @@ describe('getConnectedMcpClients', () => { }); test('includes server when refresh_token present even if access token expired', async () => { - prisma.mcpServerCredential.findMany.mockResolvedValue([ - makeCredential({ tokens: TOKEN_WITH_REFRESH, tokensExpiresAt: PAST }), + prisma.userMcpServer.findMany.mockResolvedValue([ + makeUserServer({ tokens: TOKEN_WITH_REFRESH, tokensExpiresAt: PAST }), ] as never); const result = await getConnectedMcpClients('user-1', 1); @@ -104,8 +101,8 @@ describe('getConnectedMcpClients', () => { }); test('includes server when tokensExpiresAt is null', async () => { - prisma.mcpServerCredential.findMany.mockResolvedValue([ - makeCredential({ tokensExpiresAt: null }), + prisma.userMcpServer.findMany.mockResolvedValue([ + makeUserServer({ tokensExpiresAt: null }), ] as never); const result = await getConnectedMcpClients('user-1', 1); @@ -113,20 +110,24 @@ describe('getConnectedMcpClients', () => { }); test('skips server belonging to a different org', async () => { - prisma.mcpServerCredential.findMany.mockResolvedValue([ - makeCredential({ orgId: 999 }), + prisma.userMcpServer.findMany.mockResolvedValue([ + makeUserServer({ orgId: 999 }), ] as never); const result = await getConnectedMcpClients('user-1', 1); expect(result).toHaveLength(0); }); - test('skips server the user has removed from their list', async () => { - prisma.mcpServerCredential.findMany.mockResolvedValue([ - makeCredential({ hasUserServer: false }), + test('returns server metadata from the user MCP server row', async () => { + prisma.userMcpServer.findMany.mockResolvedValue([ + makeUserServer({ tokens: TOKEN_WITH_REFRESH }), ] as never); const result = await getConnectedMcpClients('user-1', 1); - expect(result).toHaveLength(0); + expect(result[0]).toMatchObject({ + serverId: 'srv-1', + serverName: 'MyServer', + serverUrl: 'https://example.com/mcp', + }); }); -}); \ No newline at end of file +}); diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts index 47f7ee809..71d91b7e8 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -32,18 +32,21 @@ export function isTokenExpiredWithNoRefresh(tokens: OAuthTokens, tokensExpiresAt * Does NOT connect — connection is deferred to createMCPClient. */ export async function getConnectedMcpClients(userId: string, orgId: number): Promise { - const credentials = await __unsafePrisma.mcpServerCredential.findMany({ + const userServers = await __unsafePrisma.userMcpServer.findMany({ where: { userId, tokens: { not: null }, + server: { orgId }, }, - include: { + select: { + serverId: true, + name: true, + tokens: true, + tokensExpiresAt: true, server: { - include: { - userMcpServers: { - where: { userId }, - take: 1, - }, + select: { + orgId: true, + serverUrl: true, }, }, }, @@ -51,22 +54,16 @@ export async function getConnectedMcpClients(userId: string, orgId: number): Pro const clients: McpToolSet[] = []; - for (const credential of credentials) { + for (const userServer of userServers) { // Skip servers that don't belong to the current org. - if (credential.server.orgId !== orgId) { - continue; - } - - const userServer = credential.server.userMcpServers[0]; - // Skip if the user has removed this server from their list. - if (!userServer) { + if (userServer.server.orgId !== orgId) { continue; } const serverName = userServer.name; try { - const decrypted = decryptOAuthToken(credential.tokens); + const decrypted = decryptOAuthToken(userServer.tokens); if (!decrypted) { logger.warn(`Could not decrypt tokens for MCP server ${serverName}, skipping.`); continue; @@ -74,26 +71,26 @@ export async function getConnectedMcpClients(userId: string, orgId: number): Pro const tokens: OAuthTokens = JSON.parse(decrypted); - if (isTokenExpiredWithNoRefresh(tokens, credential.tokensExpiresAt)) { + if (isTokenExpiredWithNoRefresh(tokens, userServer.tokensExpiresAt)) { logger.warn(`Access token for MCP server ${serverName} is expired and has no refresh token. User ${userId} needs to re-authorize.`); continue; } const provider = new PrismaOAuthClientProvider( - credential.serverId, + userServer.serverId, userId, `${env.AUTH_URL}/api/ee/askmcp/callback`, ); const transport = new StreamableHTTPClientTransport( - new URL(credential.server.serverUrl), + new URL(userServer.server.serverUrl), { authProvider: provider }, ); clients.push({ - serverId: credential.serverId, + serverId: userServer.serverId, serverName, - serverUrl: credential.server.serverUrl, + serverUrl: userServer.server.serverUrl, transport, }); } catch (error) { @@ -102,4 +99,4 @@ export async function getConnectedMcpClients(userId: string, orgId: number): Pro } return clients; -} \ No newline at end of file +} diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index 0e0b89819..b2cd9d9d7 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -15,7 +15,7 @@ import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; * Prisma-backed OAuthClientProvider for connecting to external MCP servers. * * Stores dynamic client registration (client_id/secret) on McpServer (per-org), - * and per-user tokens + ephemeral PKCE state on McpServerCredential. + * and per-user tokens + ephemeral PKCE state on UserMcpServer. */ export class PrismaOAuthClientProvider implements OAuthClientProvider { constructor( @@ -46,7 +46,9 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { where: { id: this.serverId }, select: { clientInfo: true }, }); - if (!server?.clientInfo) return undefined; + if (!server?.clientInfo) { + return undefined; + } const decrypted = decryptOAuthToken(server.clientInfo); return decrypted ? JSON.parse(decrypted) : undefined; @@ -61,10 +63,12 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async tokens(): Promise { - const cred = await this.getOrCreateCredential(); - if (!cred.tokens) return undefined; + const userServer = await this.getUserServer(); + if (!userServer?.tokens) { + return undefined; + } - const decrypted = decryptOAuthToken(cred.tokens); + const decrypted = decryptOAuthToken(userServer.tokens); return decrypted ? JSON.parse(decrypted) : undefined; } @@ -73,22 +77,22 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { const tokensExpiresAt = tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null; - await __unsafePrisma.mcpServerCredential.update({ + await __unsafePrisma.userMcpServer.update({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId } }, data: { tokens: encrypted, tokensExpiresAt }, }); } async codeVerifier(): Promise { - const cred = await this.getOrCreateCredential(); - if (!cred.codeVerifier) { + const userServer = await this.getUserServer(); + if (!userServer?.codeVerifier) { throw new Error('No code verifier found'); } - return cred.codeVerifier; + return userServer.codeVerifier; } async saveCodeVerifier(codeVerifier: string): Promise { - await this.upsertCredential({ codeVerifier }); + await this.updateUserServer({ codeVerifier }); } async state(): Promise { @@ -96,12 +100,12 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async saveState(state: string): Promise { - await this.upsertCredential({ state }); + await this.updateUserServer({ state }); } async storedState(): Promise { - const cred = await this.getOrCreateCredential(); - return cred.state ?? undefined; + const userServer = await this.getUserServer(); + return userServer?.state ?? undefined; } async redirectToAuthorization(url: URL): Promise { @@ -134,35 +138,38 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } if (scope === 'all' || scope === 'tokens') { - await this.upsertCredential({ tokens: null }); + await this.updateUserServer({ tokens: null, tokensExpiresAt: null }); } if (scope === 'all' || scope === 'verifier') { - await this.upsertCredential({ codeVerifier: null, state: null }); + await this.updateUserServer({ codeVerifier: null, state: null }); } } - private async getOrCreateCredential() { - return __unsafePrisma.mcpServerCredential.upsert({ + private async getUserServer() { + return __unsafePrisma.userMcpServer.findUnique({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId }, }, - create: { userId: this.userId, serverId: this.serverId }, - update: {}, + select: { + tokens: true, + codeVerifier: true, + state: true, + }, }); } - private async upsertCredential(data: { + private async updateUserServer(data: { tokens?: string | null; + tokensExpiresAt?: Date | null; codeVerifier?: string | null; state?: string | null; }) { - await __unsafePrisma.mcpServerCredential.upsert({ + await __unsafePrisma.userMcpServer.update({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId }, }, - create: { userId: this.userId, serverId: this.serverId, ...data }, - update: data, + data, }); } -} \ No newline at end of file +} From c357e6221f103a58c393cee9256b8e13be92e902 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 21 May 2026 21:38:53 -0700 Subject: [PATCH 03/40] Inject Prisma into MCP OAuth provider --- .../web/src/app/api/(server)/chat/route.ts | 1 + .../api/(server)/ee/askmcp/callback/route.ts | 1 + .../api/(server)/ee/askmcp/connect/route.ts | 1 + .../ee/features/mcp/mcpClientFactory.test.ts | 15 +++++---------- .../src/ee/features/mcp/mcpClientFactory.ts | 7 ++++--- packages/web/src/features/chat/agent.ts | 10 ++++++++-- packages/web/src/features/mcp/askCodebase.ts | 1 + .../features/mcp/prismaOAuthClientProvider.ts | 18 ++++++++---------- 8 files changed, 29 insertions(+), 25 deletions(-) diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index 84b3de016..5953cbe0d 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -108,6 +108,7 @@ export const POST = apiHandler(async (req: NextRequest) => { selectedSearchScopes, }, selectedRepos: expandedRepos, + prisma, disabledMcpServerIds, model, modelName: languageModelConfig.displayName ?? languageModelConfig.model, diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index ac5bea157..ddd801b6c 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -92,6 +92,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } const provider = new PrismaOAuthClientProvider( + prisma, userServer.serverId, session.user.id, `${env.AUTH_URL}/api/ee/askmcp/callback`, diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 7382409fe..d072c9002 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -52,6 +52,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } const provider = new PrismaOAuthClientProvider( + prisma, mcpServer.id, user.id, `${env.AUTH_URL}/api/ee/askmcp/callback`, diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts index 9b6c1a0f6..8dc21a9c6 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -4,11 +4,6 @@ import type { OAuthTokens } from '@ai-sdk/mcp'; // --- Mocks --- -vi.mock('@/prisma', async () => { - const actual = await vi.importActual('@/__mocks__/prisma'); - return { ...actual }; -}); - vi.mock('@sourcebot/shared', () => ({ createLogger: () => ({ info: vi.fn(), @@ -87,7 +82,7 @@ describe('getConnectedMcpClients', () => { makeUserServer({ tokens: TOKEN_NO_REFRESH, tokensExpiresAt: PAST }), ] as never); - const result = await getConnectedMcpClients('user-1', 1); + const result = await getConnectedMcpClients(prisma, 'user-1', 1); expect(result).toHaveLength(0); }); @@ -96,7 +91,7 @@ describe('getConnectedMcpClients', () => { makeUserServer({ tokens: TOKEN_WITH_REFRESH, tokensExpiresAt: PAST }), ] as never); - const result = await getConnectedMcpClients('user-1', 1); + const result = await getConnectedMcpClients(prisma, 'user-1', 1); expect(result).toHaveLength(1); }); @@ -105,7 +100,7 @@ describe('getConnectedMcpClients', () => { makeUserServer({ tokensExpiresAt: null }), ] as never); - const result = await getConnectedMcpClients('user-1', 1); + const result = await getConnectedMcpClients(prisma, 'user-1', 1); expect(result).toHaveLength(1); }); @@ -114,7 +109,7 @@ describe('getConnectedMcpClients', () => { makeUserServer({ orgId: 999 }), ] as never); - const result = await getConnectedMcpClients('user-1', 1); + const result = await getConnectedMcpClients(prisma, 'user-1', 1); expect(result).toHaveLength(0); }); @@ -123,7 +118,7 @@ describe('getConnectedMcpClients', () => { makeUserServer({ tokens: TOKEN_WITH_REFRESH }), ] as never); - const result = await getConnectedMcpClients('user-1', 1); + const result = await getConnectedMcpClients(prisma, 'user-1', 1); expect(result[0]).toMatchObject({ serverId: 'srv-1', serverName: 'MyServer', diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts index 71d91b7e8..cbc5c2c1d 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -1,8 +1,8 @@ -import { __unsafePrisma } from '@/prisma'; import { createLogger, env, decryptOAuthToken } from '@sourcebot/shared'; import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { OAuthTokens } from '@ai-sdk/mcp'; +import type { PrismaClient } from '@sourcebot/db'; const logger = createLogger('mcp-client-factory'); @@ -31,8 +31,8 @@ export function isTokenExpiredWithNoRefresh(tokens: OAuthTokens, tokensExpiresAt * Skips servers with clearly expired tokens and no refresh token. * Does NOT connect — connection is deferred to createMCPClient. */ -export async function getConnectedMcpClients(userId: string, orgId: number): Promise { - const userServers = await __unsafePrisma.userMcpServer.findMany({ +export async function getConnectedMcpClients(prisma: PrismaClient, userId: string, orgId: number): Promise { + const userServers = await prisma.userMcpServer.findMany({ where: { userId, tokens: { not: null }, @@ -77,6 +77,7 @@ export async function getConnectedMcpClients(userId: string, orgId: number): Pro } const provider = new PrismaOAuthClientProvider( + prisma, userServer.serverId, userId, `${env.AUTH_URL}/api/ee/askmcp/callback`, diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 412c98c28..859f21428 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -4,6 +4,7 @@ import { getFileSource } from '@/features/git'; import { isServiceError } from "@/lib/utils"; import { LanguageModelV3 as AISDKLanguageModelV3 } from "@ai-sdk/provider"; import { ProviderOptions } from "@ai-sdk/provider-utils"; +import type { PrismaClient } from "@sourcebot/db"; import { createLogger, env } from "@sourcebot/shared"; import { convertToModelMessages, @@ -45,6 +46,7 @@ interface CreateMessageStreamResponseProps { chatId: string; messages: SBChatMessage[]; selectedRepos: string[]; + prisma: PrismaClient; // When undefined, MCP tools are disabled entirely (e.g. programmatic callers like askCodebase). // When an array, MCP tools are enabled for all servers not in the list. disabledMcpServerIds?: string[]; @@ -64,6 +66,7 @@ export const createMessageStream = async ({ messages, metadata, selectedRepos, + prisma, disabledMcpServerIds, model, modelName, @@ -161,6 +164,7 @@ export const createMessageStream = async ({ }, traceId, chatId, + prisma, userId, orgId, }); @@ -212,6 +216,7 @@ interface AgentOptions { onMcpServerFailed: (serverName: string) => void; traceId: string; chatId: string; + prisma: PrismaClient; userId?: string; orgId?: number; } @@ -228,7 +233,8 @@ const createAgentStream = async ({ onMcpServerDiscovered, onMcpServerFailed, traceId, - chatId, + chatId: _chatId, + prisma, userId, orgId, }: AgentOptions) => { @@ -260,7 +266,7 @@ const createAgentStream = async ({ let mcpToolSetsObj: McpToolsResult = { tools: {}, failedServers: [], serverFaviconUrls: {}, cleanup: async () => {} }; if (userId && orgId && await hasEntitlement('oauth') && disabledMcpServerIds !== undefined) { try { - const allMcpClients = await getConnectedMcpClients(userId, orgId); + const allMcpClients = await getConnectedMcpClients(prisma, userId, orgId); const mcpClients = allMcpClients.filter((c) => !disabledMcpServerIds.includes(c.serverId)); mcpToolSetsObj = await getMcpTools(mcpClients); diff --git a/packages/web/src/features/mcp/askCodebase.ts b/packages/web/src/features/mcp/askCodebase.ts index bc3a030c2..2c8186b96 100644 --- a/packages/web/src/features/mcp/askCodebase.ts +++ b/packages/web/src/features/mcp/askCodebase.ts @@ -155,6 +155,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise r.value), + prisma, model, modelName, modelProviderOptions: providerOptions, diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index b2cd9d9d7..4e79a6704 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -5,10 +5,7 @@ import type { OAuthClientMetadata, OAuthTokens, } from '@ai-sdk/mcp'; -// Note: We use the raw (unscoped) prisma client here intentionally. The user-scoped -// prisma extension only filters Repo queries, and all MCP queries in this file already -// filter explicitly by userId and/or serverId, so scoping would be a no-op. -import { __unsafePrisma } from '@/prisma'; +import type { PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; /** @@ -19,6 +16,7 @@ import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; */ export class PrismaOAuthClientProvider implements OAuthClientProvider { constructor( + private readonly prisma: PrismaClient, private readonly serverId: string, private readonly userId: string, private readonly callbackUrl: string, @@ -42,7 +40,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async clientInformation(): Promise { - const server = await __unsafePrisma.mcpServer.findUnique({ + const server = await this.prisma.mcpServer.findUnique({ where: { id: this.serverId }, select: { clientInfo: true }, }); @@ -56,7 +54,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { async saveClientInformation(info: OAuthClientInformation): Promise { const encrypted = encryptOAuthToken(JSON.stringify(info)); - await __unsafePrisma.mcpServer.update({ + await this.prisma.mcpServer.update({ where: { id: this.serverId }, data: { clientInfo: encrypted }, }); @@ -77,7 +75,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { const tokensExpiresAt = tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null; - await __unsafePrisma.userMcpServer.update({ + await this.prisma.userMcpServer.update({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId } }, data: { tokens: encrypted, tokensExpiresAt }, }); @@ -131,7 +129,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { scope: 'all' | 'client' | 'tokens' | 'verifier', ): Promise { if (scope === 'all' || scope === 'client') { - await __unsafePrisma.mcpServer.update({ + await this.prisma.mcpServer.update({ where: { id: this.serverId }, data: { clientInfo: null }, }); @@ -147,7 +145,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } private async getUserServer() { - return __unsafePrisma.userMcpServer.findUnique({ + return this.prisma.userMcpServer.findUnique({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId }, }, @@ -165,7 +163,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { codeVerifier?: string | null; state?: string | null; }) { - await __unsafePrisma.userMcpServer.update({ + await this.prisma.userMcpServer.update({ where: { userId_serverId: { userId: this.userId, serverId: this.serverId }, }, From 1e3fdc1bc3993f071800ed1cd6161c5167333d0e Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Fri, 22 May 2026 10:00:58 -0700 Subject: [PATCH 04/40] Scope MCP user server queries --- .../web/src/features/mcp/prismaScope.test.ts | 444 ++++++++++++++++++ packages/web/src/features/mcp/prismaScope.ts | 366 +++++++++++++++ packages/web/src/middleware/withAuth.test.ts | 35 ++ packages/web/src/prisma.ts | 2 + 4 files changed, 847 insertions(+) create mode 100644 packages/web/src/features/mcp/prismaScope.test.ts create mode 100644 packages/web/src/features/mcp/prismaScope.ts diff --git a/packages/web/src/features/mcp/prismaScope.test.ts b/packages/web/src/features/mcp/prismaScope.test.ts new file mode 100644 index 000000000..c5092acbb --- /dev/null +++ b/packages/web/src/features/mcp/prismaScope.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { UserWithAccounts } from '@sourcebot/db'; +import { getMcpPrismaQueryExtension, scopeUserMcpServerWhere } from './prismaScope'; + +const user = { + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + hashedPassword: null, + emailVerified: null, + image: null, + sessionVersion: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + accounts: [], +} satisfies UserWithAccounts; + +const callQuery = vi.fn(async (args: unknown) => args); + +const resetQuery = () => { + callQuery.mockClear(); + return callQuery; +}; + +const callAllOperations = ( + model: { + $allOperations: (params: { + operation: string; + args: unknown; + query: (args: unknown) => Promise; + }) => Promise; + }, + operation: string, + args: unknown, + query = resetQuery(), +) => model.$allOperations({ operation, args, query }); + +describe('scopeUserMcpServerWhere', () => { + test('merges existing filters with the authenticated user id', () => { + expect(scopeUserMcpServerWhere({ tokens: { not: null } }, user)).toEqual({ + AND: [ + { tokens: { not: null } }, + { userId: 'user-1' }, + ], + }); + }); + + test('fails closed for anonymous users', () => { + expect(scopeUserMcpServerWhere(undefined, undefined)).toEqual({ + AND: [ + { userId: '__sourcebot_anonymous_user__' }, + { userId: '__sourcebot_no_authenticated_user__' }, + ], + }); + }); +}); + +describe('getMcpPrismaQueryExtension', () => { + test('scopes list-style UserMcpServer reads', async () => { + const extension = getMcpPrismaQueryExtension(user); + const result = await extension.userMcpServer.findMany({ + args: { where: { tokens: { not: null } } }, + query: resetQuery(), + }); + + expect(result).toEqual({ + where: { + AND: [ + { tokens: { not: null } }, + { userId: 'user-1' }, + ], + }, + }); + }); + + test('returns null for anonymous or mismatched findUnique queries', async () => { + const anonymousExtension = getMcpPrismaQueryExtension(); + const mismatchedExtension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(anonymousExtension.userMcpServer.findUnique({ + args: { where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } } }, + query, + })).resolves.toBeNull(); + await expect(mismatchedExtension.userMcpServer.findUnique({ + args: { where: { userId_serverId: { userId: 'user-2', serverId: 'server-1' } } }, + query, + })).resolves.toBeNull(); + + expect(query).not.toHaveBeenCalled(); + }); + + test('allows matching findUnique queries through', async () => { + const extension = getMcpPrismaQueryExtension(user); + const args = { where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } } }; + + await expect(extension.userMcpServer.findUnique({ + args, + query: resetQuery(), + })).resolves.toBe(args); + }); + + test('rejects creates for anonymous or mismatched users', async () => { + const anonymousExtension = getMcpPrismaQueryExtension(); + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(anonymousExtension.userMcpServer.create({ + args: { data: { userId: 'user-1', serverId: 'server-1', name: 'Linear' } }, + query, + })).rejects.toThrow('requires an authenticated user'); + await expect(extension.userMcpServer.create({ + args: { data: { userId: 'user-2', serverId: 'server-1', name: 'Linear' } }, + query, + })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('allows checked creates that connect the authenticated user', async () => { + const extension = getMcpPrismaQueryExtension(user); + const args = { + data: { + user: { connect: { id: 'user-1' } }, + server: { connect: { id: 'server-1' } }, + name: 'Linear', + }, + }; + + await expect(extension.userMcpServer.create({ + args, + query: resetQuery(), + })).resolves.toBe(args); + }); + + test('rejects checked creates that do not connect the authenticated user', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(extension.userMcpServer.create({ + args: { + data: { + user: { connect: { id: 'user-2' } }, + server: { connect: { id: 'server-1' } }, + name: 'Linear', + }, + }, + query, + })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); + await expect(extension.userMcpServer.create({ + args: { + data: { + user: { create: { id: 'user-1', email: 'test@example.com' } }, + server: { connect: { id: 'server-1' } }, + name: 'Linear', + }, + }, + query, + })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects mismatched update/delete composite keys', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(extension.userMcpServer.update({ + args: { + where: { userId_serverId: { userId: 'user-2', serverId: 'server-1' } }, + data: { name: 'Linear' }, + }, + query, + })).rejects.toThrow('cannot access UserMcpServer rows for another user'); + await expect(extension.userMcpServer.delete({ + args: { where: { userId_serverId: { userId: 'user-2', serverId: 'server-1' } } }, + query, + })).rejects.toThrow('cannot access UserMcpServer rows for another user'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects attempts to mutate UserMcpServer ownership', async () => { + const extension = getMcpPrismaQueryExtension(user); + + await expect(extension.userMcpServer.update({ + args: { + where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } }, + data: { userId: 'user-2' }, + }, + query: resetQuery(), + })).rejects.toThrow('cannot change UserMcpServer identity'); + await expect(extension.userMcpServer.update({ + args: { + where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } }, + data: { server: { connect: { id: 'server-2' } } }, + }, + query: resetQuery(), + })).rejects.toThrow('cannot change UserMcpServer identity'); + await expect(extension.userMcpServer.upsert({ + args: { + where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } }, + create: { userId: 'user-1', serverId: 'server-1', name: 'Linear' }, + update: { user: { connect: { id: 'user-2' } } }, + }, + query: resetQuery(), + })).rejects.toThrow('cannot change UserMcpServer identity'); + }); + + test('scopes updateMany and deleteMany', async () => { + const extension = getMcpPrismaQueryExtension(user); + + await expect(extension.userMcpServer.updateMany({ + args: { where: { tokens: { not: null } }, data: { state: null } }, + query: resetQuery(), + })).resolves.toEqual({ + where: { + AND: [ + { tokens: { not: null } }, + { userId: 'user-1' }, + ], + }, + data: { state: null }, + }); + await expect(extension.userMcpServer.deleteMany({ + args: { where: { serverId: 'server-1' } }, + query: resetQuery(), + })).resolves.toEqual({ + where: { + AND: [ + { serverId: 'server-1' }, + { userId: 'user-1' }, + ], + }, + }); + }); + + test('scopes returning bulk UserMcpServer operations', async () => { + const extension = getMcpPrismaQueryExtension(user); + + await expect(extension.userMcpServer.createManyAndReturn({ + args: { data: { userId: 'user-2', serverId: 'server-1', name: 'Linear' } }, + query: resetQuery(), + })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); + await expect(extension.userMcpServer.updateManyAndReturn({ + args: { where: { serverId: 'server-1' }, data: { state: null } }, + query: resetQuery(), + })).resolves.toEqual({ + where: { + AND: [ + { serverId: 'server-1' }, + { userId: 'user-1' }, + ], + }, + data: { state: null }, + }); + }); + + test('rejects nested UserMcpServer relation access through direct UserMcpServer queries', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(extension.userMcpServer.findMany({ + args: { + include: { + server: { + include: { + userMcpServers: true, + }, + }, + }, + }, + query, + })).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects nested UserMcpServer writes through McpServer operations', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(callAllOperations( + extension.mcpServer, + 'update', + { + where: { id: 'server-1' }, + data: { userMcpServers: { create: { userId: 'user-1', name: 'Linear' } } }, + }, + query, + )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects nested UserMcpServer reads and writes through parent models', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(callAllOperations( + extension.mcpServer, + 'findUnique', + { + where: { id: 'server-1' }, + include: { userMcpServers: true }, + }, + query, + )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); + await expect(callAllOperations( + extension.user, + 'findMany', + { + where: { userMcpServers: { some: { serverId: 'server-1' } } }, + }, + query, + )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); + await expect(callAllOperations( + extension.user, + 'update', + { + where: { id: 'user-1' }, + data: { userMcpServers: { create: { serverId: 'server-1', name: 'Linear' } } }, + }, + query, + )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects transitive MCP relation access through Org and UserToOrg operations', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(callAllOperations( + extension.org, + 'findUnique', + { + where: { id: 1 }, + include: { + mcpServers: { + include: { + userMcpServers: true, + }, + }, + }, + }, + query, + )).rejects.toThrow('cannot access MCP server relations through a parent relation'); + await expect(callAllOperations( + extension.org, + 'update', + { + where: { id: 1 }, + data: { + mcpServers: { + create: { + serverUrl: 'https://mcp.linear.app/mcp', + userMcpServers: { + create: { userId: 'user-1', name: 'Linear' }, + }, + }, + }, + }, + }, + query, + )).rejects.toThrow('cannot access MCP server relations through a parent relation'); + await expect(callAllOperations( + extension.userToOrg, + 'findMany', + { + include: { + org: { + include: { + mcpServers: { + include: { + userMcpServers: true, + }, + }, + }, + }, + }, + }, + query, + )).rejects.toThrow('cannot access MCP server relations through a parent relation'); + + expect(query).not.toHaveBeenCalled(); + }); + + test('allows JSON metadata payloads with relation-like keys', async () => { + const extension = getMcpPrismaQueryExtension(user); + const args = { + where: { id: 1 }, + data: { + metadata: { + mcpServers: 'display-state', + userMcpServers: { collapsed: true }, + }, + }, + }; + + await expect(callAllOperations(extension.org, 'update', args)).resolves.toBe(args); + }); + + test('passes safe parent-model operations through the compact hooks', async () => { + const extension = getMcpPrismaQueryExtension(user); + const args = { where: { orgId: 1 } }; + + await expect(callAllOperations(extension.userToOrg, 'findMany', args)).resolves.toBe(args); + }); + + test('allows single user deletes but blocks bulk user deletes', async () => { + const extension = getMcpPrismaQueryExtension(user); + const args = { where: { id: 'user-2' } }; + const query = resetQuery(); + + await expect(callAllOperations(extension.user, 'delete', args, query)).resolves.toBe(args); + expect(query).toHaveBeenCalledTimes(1); + query.mockClear(); + + await expect(callAllOperations(extension.user, 'deleteMany', { where: {} }, query)) + .rejects.toThrow('user.deleteMany cannot delete users through a user-scoped client'); + expect(query).not.toHaveBeenCalled(); + }); + + test('rejects shared McpServer deletes through the scoped client', async () => { + const extension = getMcpPrismaQueryExtension(user); + const query = resetQuery(); + + await expect(callAllOperations( + extension.mcpServer, + 'delete', + { where: { id: 'server-1' } }, + query, + )).rejects.toThrow('cannot delete shared McpServer rows through a user-scoped client'); + await expect(callAllOperations( + extension.mcpServer, + 'deleteMany', + { where: { orgId: 1 } }, + query, + )).rejects.toThrow('cannot delete shared McpServer rows through a user-scoped client'); + + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/features/mcp/prismaScope.ts b/packages/web/src/features/mcp/prismaScope.ts new file mode 100644 index 000000000..9e3089f24 --- /dev/null +++ b/packages/web/src/features/mcp/prismaScope.ts @@ -0,0 +1,366 @@ +import { Prisma, UserWithAccounts } from '@sourcebot/db'; + +type QueryHookParams = { + args: TArgs; + query: (args: TArgs) => Promise; +}; + +type AllOperationsHookParams = { + operation: string; + args: unknown; + query: (args: unknown) => Promise; +}; + +type UserMcpServerWhereArgs = { + where?: Prisma.UserMcpServerWhereInput; +}; + +type UserMcpServerWhereUniqueArgs = { + where: Prisma.UserMcpServerWhereUniqueInput; +}; + +type UserMcpServerCreateArgs = { + data: unknown; +}; + +type UserMcpServerUpdateArgs = UserMcpServerWhereUniqueArgs & { + data: unknown; +}; + +type UserMcpServerUpsertArgs = UserMcpServerWhereUniqueArgs & { + create: unknown; + update: unknown; +}; + +// Deliberately impossible filter — AND-ing two different userId values guarantees zero rows. +// Used as the fallback when no user is authenticated, so anonymous queries see nothing. +// Prisma doesn't expose a "match nothing" primitive, so this is the standard workaround. +const anonymousUserScope: Prisma.UserMcpServerWhereInput = { + AND: [ + { userId: '__sourcebot_anonymous_user__' }, + { userId: '__sourcebot_no_authenticated_user__' }, + ], +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const userScopeWhere = (user?: UserWithAccounts): Prisma.UserMcpServerWhereInput => + user ? { userId: user.id } : anonymousUserScope; + +export const scopeUserMcpServerWhere = ( + where: Prisma.UserMcpServerWhereInput | undefined, + user?: UserWithAccounts, +): Prisma.UserMcpServerWhereInput => { + const scope = userScopeWhere(user); + return where ? { AND: [where, scope] } : scope; +}; + +const scopeUserMcpServerReadArgs = ( + args: TArgs, + user?: UserWithAccounts, +): TArgs => ({ + ...args, + where: scopeUserMcpServerWhere(args.where, user), +}); + +const requireAuthenticatedUser = ( + user: UserWithAccounts | undefined, + operation: string, +): UserWithAccounts => { + if (!user) { + throw new Error(`${operation} requires an authenticated user.`); + } + return user; +}; + +const uniqueWhereUserId = (where: Prisma.UserMcpServerWhereUniqueInput): string | undefined => { + const compositeKey = where.userId_serverId; + return isRecord(compositeKey) && typeof compositeKey.userId === 'string' + ? compositeKey.userId + : undefined; +}; + +export const isUserMcpServerUniqueWhereForUser = ( + where: Prisma.UserMcpServerWhereUniqueInput, + user?: UserWithAccounts, +) => !!user && uniqueWhereUserId(where) === user.id; + +const assertUserMcpServerUniqueWhereForUser = ( + where: Prisma.UserMcpServerWhereUniqueInput, + user: UserWithAccounts | undefined, + operation: string, +) => { + const authenticatedUser = requireAuthenticatedUser(user, operation); + if (!isUserMcpServerUniqueWhereForUser(where, authenticatedUser)) { + throw new Error(`${operation} cannot access UserMcpServer rows for another user.`); + } +}; + +const assertNoIdentityMutation = (data: unknown, operation: string) => { + if (!isRecord(data)) { + return; + } + + if ('userId' in data || 'user' in data || 'serverId' in data || 'server' in data) { + throw new Error(`${operation} cannot change UserMcpServer identity.`); + } +}; + +// Extracts the userId from a Prisma relation connect object. +// Prisma's connect syntax for a relation looks like: { connect: { id: "some-id" } } +const connectedUserId = (userRelation: unknown): string | undefined => { + if (!isRecord(userRelation) || !('connect' in userRelation)) { + return undefined; + } + + const connect = userRelation.connect; + if (!isRecord(connect) || !('id' in connect) || typeof connect.id !== 'string') { + return undefined; + } + + return connect.id; +}; + +const createDataUserId = (row: unknown): string | undefined => { + if (!isRecord(row)) { + return undefined; + } + const scalarUserId = typeof row.userId === 'string' ? row.userId : undefined; + const relationUserId = row.user === undefined ? undefined : connectedUserId(row.user); + + if (row.user !== undefined && relationUserId === undefined) { + return undefined; + } + if (scalarUserId !== undefined && relationUserId !== undefined && scalarUserId !== relationUserId) { + return undefined; + } + + return relationUserId ?? scalarUserId; +}; + +const assertCreateDataForUser = ( + data: unknown, + user: UserWithAccounts | undefined, + operation: string, +) => { + const authenticatedUser = requireAuthenticatedUser(user, operation); + + const rows = Array.isArray(data) ? data : [data]; + for (const row of rows) { + if (createDataUserId(row) !== authenticatedUser.id) { + throw new Error(`${operation} must create UserMcpServer rows for the authenticated user.`); + } + } +}; + +const scopeUserMcpServerWriteManyArgs = ( + args: TArgs, + user: UserWithAccounts | undefined, + operation: string, +): TArgs => { + const authenticatedUser = requireAuthenticatedUser(user, operation); + return scopeUserMcpServerReadArgs(args, authenticatedUser); +}; + +const PRISMA_SELECTION_KEYS = new Set(['include', 'select']); +const PRISMA_STRUCTURAL_KEYS = new Set([ + ...PRISMA_SELECTION_KEYS, + 'where', + 'orderBy', + 'data', + 'create', + 'connectOrCreate', + 'update', + 'updateMany', + 'upsert', + 'delete', + 'deleteMany', + 'AND', + 'OR', + 'NOT', + 'some', + 'none', + 'every', + 'is', + 'isNot', +]); +const MCP_RELATION_BRIDGE_KEYS = new Set([ + 'user', + 'server', + 'org', + 'orgs', + 'members', +]); + +const containsPrismaRelationAccess = ( + value: unknown, + relationNames: string[], + isSelectionObject = false, +): boolean => { + if (Array.isArray(value)) { + return value.some((item) => containsPrismaRelationAccess(item, relationNames, isSelectionObject)); + } + if (!isRecord(value)) { + return false; + } + if (relationNames.some((relationName) => relationName in value)) { + return true; + } + + return Object.entries(value).some(([key, nestedValue]) => { + if (PRISMA_SELECTION_KEYS.has(key)) { + return containsPrismaRelationAccess(nestedValue, relationNames, true); + } + + if (isSelectionObject || PRISMA_STRUCTURAL_KEYS.has(key) || MCP_RELATION_BRIDGE_KEYS.has(key)) { + return containsPrismaRelationAccess(nestedValue, relationNames); + } + + return false; + }); +}; + +const assertNoUserMcpServerRelationAccess = (args: unknown, operation: string) => { + if (containsPrismaRelationAccess(args, ['userMcpServers'])) { + throw new Error(`${operation} cannot access UserMcpServer rows through a parent relation.`); + } +}; + +const assertNoMcpServerRelationAccess = (args: unknown, operation: string) => { + if (containsPrismaRelationAccess(args, ['mcpServers', 'userMcpServers'])) { + throw new Error(`${operation} cannot access MCP server relations through a parent relation.`); + } +}; + +const rejectSharedMcpServerDelete = (operation: string) => { + throw new Error(`${operation} cannot delete shared McpServer rows through a user-scoped client.`); +}; + +const rejectUserDeleteMany = () => { + throw new Error('user.deleteMany cannot delete users through a user-scoped client.'); +}; + +const guardMcpParentOperation = ( + modelName: string, + guard: (args: unknown, operation: string) => void, +) => async ({ operation, args, query }: AllOperationsHookParams) => { + guard(args, `${modelName}.${operation}`); + return query(args); +}; + +export const getMcpPrismaQueryExtension = (user?: UserWithAccounts) => ({ + userMcpServer: { + async findMany({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.findMany'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async findFirst({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.findFirst'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async findFirstOrThrow({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.findFirstOrThrow'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async findUnique({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.findUnique'); + // Preserve Prisma's nullable "not found" semantics for scoped reads. Callers that + // need a hard failure should use findUniqueOrThrow; write paths throw on mismatch. + return isUserMcpServerUniqueWhereForUser(args.where, user) ? query(args) : null; + }, + async findUniqueOrThrow({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.findUniqueOrThrow'); + assertUserMcpServerUniqueWhereForUser(args.where, user, 'userMcpServer.findUniqueOrThrow'); + return query(args); + }, + async count({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.count'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async aggregate({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.aggregate'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async groupBy({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.groupBy'); + return query(scopeUserMcpServerReadArgs(args, user)); + }, + async create({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.create'); + assertCreateDataForUser((args as UserMcpServerCreateArgs).data, user, 'userMcpServer.create'); + return query(args); + }, + async createMany({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.createMany'); + assertCreateDataForUser((args as UserMcpServerCreateArgs).data, user, 'userMcpServer.createMany'); + return query(args); + }, + async createManyAndReturn({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.createManyAndReturn'); + assertCreateDataForUser((args as UserMcpServerCreateArgs).data, user, 'userMcpServer.createManyAndReturn'); + return query(args); + }, + async update({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.update'); + assertUserMcpServerUniqueWhereForUser(args.where, user, 'userMcpServer.update'); + assertNoIdentityMutation((args as UserMcpServerUpdateArgs).data, 'userMcpServer.update'); + return query(args); + }, + async updateMany({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.updateMany'); + requireAuthenticatedUser(user, 'userMcpServer.updateMany'); + assertNoIdentityMutation((args as UserMcpServerUpdateArgs).data, 'userMcpServer.updateMany'); + return query(scopeUserMcpServerWriteManyArgs(args, user, 'userMcpServer.updateMany')); + }, + async updateManyAndReturn({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.updateManyAndReturn'); + requireAuthenticatedUser(user, 'userMcpServer.updateManyAndReturn'); + assertNoIdentityMutation((args as UserMcpServerUpdateArgs).data, 'userMcpServer.updateManyAndReturn'); + return query(scopeUserMcpServerWriteManyArgs(args, user, 'userMcpServer.updateManyAndReturn')); + }, + async delete({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.delete'); + assertUserMcpServerUniqueWhereForUser(args.where, user, 'userMcpServer.delete'); + return query(args); + }, + async deleteMany({ args, query }: QueryHookParams) { + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.deleteMany'); + return query(scopeUserMcpServerWriteManyArgs(args, user, 'userMcpServer.deleteMany')); + }, + async upsert({ args, query }: QueryHookParams) { + const upsertArgs = args as UserMcpServerUpsertArgs; + assertNoUserMcpServerRelationAccess(args, 'userMcpServer.upsert'); + assertUserMcpServerUniqueWhereForUser(args.where, user, 'userMcpServer.upsert'); + assertCreateDataForUser(upsertArgs.create, user, 'userMcpServer.upsert'); + assertNoIdentityMutation(upsertArgs.update, 'userMcpServer.upsert'); + return query(args); + }, + }, + user: { + async $allOperations({ operation, args, query }: AllOperationsHookParams) { + if (operation === 'deleteMany') { + rejectUserDeleteMany(); + } + // The owner-only user deletion API intentionally deletes one user and relies on + // cascade to remove that user's rows. Bulk deletes stay blocked above. + assertNoUserMcpServerRelationAccess(args, `user.${operation}`); + return query(args); + }, + }, + mcpServer: { + async $allOperations({ operation, args, query }: AllOperationsHookParams) { + if (operation === 'delete' || operation === 'deleteMany') { + rejectSharedMcpServerDelete(`mcpServer.${operation}`); + } + assertNoUserMcpServerRelationAccess(args, `mcpServer.${operation}`); + return query(args); + }, + }, + org: { + $allOperations: guardMcpParentOperation('org', assertNoMcpServerRelationAccess), + }, + userToOrg: { + $allOperations: guardMcpParentOperation('userToOrg', assertNoMcpServerRelationAccess), + }, +}); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 862677df9..6da2a9afe 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -6,6 +6,7 @@ import { MOCK_API_KEY, MOCK_OAUTH_TOKEN, MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, pris import { OrgRole } from '@sourcebot/db'; import { ErrorCode } from '../lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; +import { userScopedPrismaClientExtension } from '@/prisma'; const mocks = vi.hoisted(() => { return { @@ -80,6 +81,7 @@ const createMockSession = (overrides: Partial = {}): Session => ({ beforeEach(() => { vi.clearAllMocks(); + vi.mocked(userScopedPrismaClientExtension).mockReset(); mocks.auth.mockResolvedValue(null); mocks.headers.mockResolvedValue(new Headers()); mocks.hasEntitlement.mockReturnValue(false); @@ -471,6 +473,39 @@ describe('getAuthContext', () => { }); describe('withAuth', () => { + test('should pass the scoped prisma client from $extends to the callback', async () => { + const userId = 'test-user-id'; + const user = { + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }; + const extension = { query: { userMcpServer: {} } }; + const scopedPrisma = { scoped: true }; + + prisma.user.findUnique.mockResolvedValue(user); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + vi.mocked(userScopedPrismaClientExtension).mockResolvedValue(extension as never); + prisma.$extends.mockReturnValue(scopedPrisma as never); + setMockSession(createMockSession({ user: { id: userId } })); + + const cb = vi.fn(); + await withAuth(cb); + + expect(userScopedPrismaClientExtension).toHaveBeenCalledWith(user); + expect(prisma.$extends).toHaveBeenCalledWith(extension); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ + prisma: scopedPrisma, + })); + }); + test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ diff --git a/packages/web/src/prisma.ts b/packages/web/src/prisma.ts index f863f5ef7..0496d8c9b 100644 --- a/packages/web/src/prisma.ts +++ b/packages/web/src/prisma.ts @@ -2,6 +2,7 @@ import 'server-only'; import { env, getDBConnectionString } from "@sourcebot/shared"; import { Prisma, PrismaClient, UserWithAccounts } from "@sourcebot/db"; import { hasEntitlement } from "@/lib/entitlements"; +import { getMcpPrismaQueryExtension } from "@/features/mcp/prismaScope"; // @see: https://authjs.dev/getting-started/adapters/prisma const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } @@ -35,6 +36,7 @@ export const userScopedPrismaClientExtension = async (user?: UserWithAccounts) = (prisma) => { return prisma.$extends({ query: { + ...getMcpPrismaQueryExtension(user), ...(hasPermissionSyncing ? { repo: { async $allOperations({ args, query }) { From da3f88570bed19e01a369dd068dd460e5661b95b Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Sun, 24 May 2026 09:18:25 -0700 Subject: [PATCH 05/40] Add org-approved MCP servers --- .../migration.sql | 27 +++ packages/db/prisma/schema.prisma | 11 +- .../settings/mcpServers/mcpServersPage.tsx | 151 +++++++------- .../app/(app)/settings/mcpServers/page.tsx | 17 +- .../(server)/ee/askmcp/callback/route.test.ts | 119 +++++++++++ .../api/(server)/ee/askmcp/callback/route.ts | 54 +++-- .../(server)/ee/askmcp/connect/route.test.ts | 138 +++++++++++++ .../api/(server)/ee/askmcp/connect/route.ts | 87 ++++++-- .../(server)/ee/askmcp/servers/route.test.ts | 128 ++++++++++++ .../api/(server)/ee/askmcp/servers/route.ts | 46 +++-- .../web/src/ee/features/mcp/actions.test.ts | 111 ++++++++++ packages/web/src/ee/features/mcp/actions.ts | 190 ++++++++---------- .../ee/features/mcp/mcpClientFactory.test.ts | 4 +- .../src/ee/features/mcp/mcpClientFactory.ts | 21 +- .../src/ee/features/mcp/mcpToolSets.test.ts | 1 + .../web/src/ee/features/mcp/mcpToolSets.ts | 6 +- .../mcp/prismaOAuthClientProvider.test.ts | 115 +++++++++++ .../features/mcp/prismaOAuthClientProvider.ts | 159 ++++++++++++--- .../web/src/features/mcp/prismaScope.test.ts | 21 +- 19 files changed, 1111 insertions(+), 295 deletions(-) create mode 100644 packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts create mode 100644 packages/web/src/ee/features/mcp/actions.test.ts create mode 100644 packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts diff --git a/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql b/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql new file mode 100644 index 000000000..99d1bc446 --- /dev/null +++ b/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql @@ -0,0 +1,27 @@ +-- Add org-approved display/tool identity to shared MCP servers. +ALTER TABLE "McpServer" ADD COLUMN "name" TEXT; +ALTER TABLE "McpServer" ADD COLUMN "sanitizedName" TEXT; + +-- This branch has not shipped, but keep local development databases migratable. +UPDATE "McpServer" +SET "name" = COALESCE( + ( + SELECT "UserMcpServer"."name" + FROM "UserMcpServer" + WHERE "UserMcpServer"."serverId" = "McpServer"."id" + ORDER BY "UserMcpServer"."createdAt" ASC + LIMIT 1 + ), + "McpServer"."serverUrl" +); + +UPDATE "McpServer" +SET "sanitizedName" = regexp_replace(lower("name"), '[^a-z0-9]', '_', 'g'); + +ALTER TABLE "McpServer" ALTER COLUMN "name" SET NOT NULL; +ALTER TABLE "McpServer" ALTER COLUMN "sanitizedName" SET NOT NULL; + +-- Remove per-user display identity now that MCP servers are org-approved. +ALTER TABLE "UserMcpServer" DROP COLUMN "name"; + +CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b55699878..1f7b97bec 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -650,8 +650,10 @@ model ChangelogEntry { /// An external MCP server endpoint, unique per org. /// Stores the dynamic client registration (client_id/client_secret) once per org. model McpServer { - id String @id @default(cuid()) - serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp") + id String @id @default(cuid()) + name String /// Org-approved display name (e.g., "Linear") + sanitizedName String /// Stable tool-name prefix (e.g., "linear") + serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp") /// Dynamic client registration result (RFC 7591). /// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at } @@ -667,10 +669,11 @@ model McpServer { updatedAt DateTime @updatedAt @@unique([serverUrl, orgId]) + @@unique([orgId, sanitizedName]) } /// A user's personal connection to an MCP server. -/// Stores the user-chosen display name plus per-user OAuth tokens and ephemeral auth-flow state. +/// Stores per-user OAuth tokens and ephemeral auth-flow state. model UserMcpServer { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @@ -678,8 +681,6 @@ model UserMcpServer { server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) serverId String - name String /// User-chosen display name (e.g., "Linear") - /// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens. tokens String? diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx index 1263a7c02..5009511df 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx @@ -34,9 +34,10 @@ interface McpServersPageProps { callbackStatus?: string; callbackServer?: string; callbackMessage?: string; + canManageMcpServers: boolean; } -export function McpServersPage({ callbackStatus, callbackServer, callbackMessage }: McpServersPageProps) { +export function McpServersPage({ callbackStatus, callbackServer, callbackMessage, canManageMcpServers }: McpServersPageProps) { const { toast } = useToast(); const didHandleCallbackRef = useRef(false); @@ -132,50 +133,54 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage

MCP Servers

- Connect external MCP servers to use with Ask Sourcebot. + {canManageMcpServers + ? "Approve external MCP servers for your workspace." + : "Connect to workspace-approved MCP servers to use them with Ask Sourcebot."}

- - - - - - - Add MCP Server - -
-
- - setNewServerName(e.target.value)} - placeholder="e.g. Linear" - /> -
-
- - setNewServerUrl(e.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> -
-
- - - - -
-
+ + + + Add MCP Server + +
+
+ + setNewServerName(e.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(e.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+ + )}
{/* Server list */} @@ -201,7 +206,9 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage

No MCP servers yet

- Click "Add MCP Server" above to connect an external MCP server. + {canManageMcpServers + ? "Add an MCP server above to make it available to workspace members." + : "No MCP servers have been approved for this workspace yet."}

@@ -218,35 +225,37 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage {server.serverUrl} - - - - - - - Delete MCP Server - - Are you sure you want to remove {server.name || server.serverUrl}? This will remove the server and your credentials from your list. - - - - Cancel - handleDelete(server.id)} - disabled={deletingServerId === server.id} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + {canManageMcpServers && ( + + + + + + + Delete MCP Server + + Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this server. + + + + Cancel + handleDelete(server.id)} + disabled={deletingServerId === server.id} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deletingServerId === server.id ? "Deleting..." : "Delete"} + + + + + )} @@ -282,4 +291,4 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage )} ); -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/settings/mcpServers/page.tsx b/packages/web/src/app/(app)/settings/mcpServers/page.tsx index edfd780d6..7c6c43ad1 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/page.tsx +++ b/packages/web/src/app/(app)/settings/mcpServers/page.tsx @@ -1,6 +1,8 @@ import { McpServersPage } from "./mcpServersPage"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { OrgRole } from "@sourcebot/db"; -interface PageProps { +interface PageProps extends Record { searchParams: Promise<{ status?: string; server?: string; @@ -8,7 +10,14 @@ interface PageProps { }>; } -export default async function Page({ searchParams }: PageProps) { +export default authenticatedPage(async ({ role }, { searchParams }) => { const { status, server, message } = await searchParams; - return ; -} + return ( + + ); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts new file mode 100644 index 000000000..d5f53f136 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + hasEntitlement: vi.fn(), + mcpAuth: vi.fn(), + unsafePrisma: { + mcpServer: { + updateMany: vi.fn(), + }, + userMcpServer: { + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + userToOrg: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock('server-only', () => ({})); +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); +vi.mock('@/auth', () => ({ + auth: mocks.auth, +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/prisma', () => ({ + prisma: mocks.unsafePrisma, + __unsafePrisma: mocks.unsafePrisma, +})); +vi.mock('@sourcebot/shared', () => ({ + env: { + AUTH_URL: 'https://sourcebot.example.com', + }, + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), + decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), +})); +vi.mock('@ai-sdk/mcp', () => ({ + auth: mocks.mcpAuth, +})); + +const { GET } = await import('./route'); + +function createRequest() { + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/callback?code=code-1&state=state-1', { + method: 'GET', + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue({ user: { id: 'user-1' } }); + mocks.hasEntitlement.mockResolvedValue(true); + mocks.unsafePrisma.userMcpServer.findFirst.mockResolvedValue({ + serverId: 'server-1', + server: { + orgId: 1, + name: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + }, + }); + mocks.unsafePrisma.userMcpServer.update.mockResolvedValue({ userId: 'user-1', serverId: 'server-1' }); + mocks.unsafePrisma.userToOrg.findUnique.mockResolvedValue({ orgId: 1, userId: 'user-1' }); +}); + +describe('GET /api/ee/askmcp/callback', () => { + test('redirects with a friendly reconnect error when callback auth cannot complete', async () => { + mocks.mcpAuth.mockImplementation(async (provider) => { + expect('saveClientInformation' in provider).toBe(false); + await provider.invalidateCredentials('all'); + throw new Error('invalid_client'); + }); + + const response = await GET(createRequest()); + const location = response.headers.get('location'); + + expect(location).toBeTruthy(); + expect(location).toContain('/settings/mcpServers'); + expect(location).toContain('status=error'); + expect(new URL(location ?? '').searchParams.get('message')).toContain('Please reconnect the server'); + expect(mocks.unsafePrisma.userMcpServer.findFirst).toHaveBeenCalledWith({ + where: { + state: 'state-1', + userId: 'user-1', + }, + select: { + serverId: true, + server: { + select: { + orgId: true, + name: true, + serverUrl: true, + }, + }, + }, + }); + expect(mocks.unsafePrisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + codeVerifier: null, + state: null, + }, + }); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index ddd801b6c..e564d3839 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -12,6 +12,14 @@ import { auth } from '@/auth'; import { NextRequest, NextResponse } from 'next/server'; const logger = createLogger('mcp-oauth-callback'); +const reconnectMessage = 'This MCP server authorization could not be completed. Please reconnect the server.'; + +function redirectToSettingsError(message: string) { + const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); + settingsUrl.searchParams.set('status', 'error'); + settingsUrl.searchParams.set('message', message); + return NextResponse.redirect(settingsUrl); +} // eslint-disable-next-line authz/require-auth-wrapper -- OAuth redirect callback validates the active session with auth() and filters all queries by userId. export const GET = apiHandler(async (request: NextRequest) => { @@ -58,10 +66,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }, select: { serverId: true, - name: true, server: { select: { orgId: true, + name: true, serverUrl: true, }, }, @@ -91,26 +99,42 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - const provider = new PrismaOAuthClientProvider( + const provider = new PrismaOAuthClientProvider({ prisma, - userServer.serverId, - session.user.id, - `${env.AUTH_URL}/api/ee/askmcp/callback`, - ); - - const result = await mcpAuth(provider, { - serverUrl: new URL(userServer.server.serverUrl), - authorizationCode: code, - callbackState: state, + serverId: userServer.serverId, + orgId: userServer.server.orgId, + userId: session.user.id, + callbackUrl: `${env.AUTH_URL}/api/ee/askmcp/callback`, }); - // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. - await provider.invalidateCredentials('verifier'); - const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); + let result: Awaited>; + + try { + result = await mcpAuth(provider, { + serverUrl: new URL(userServer.server.serverUrl), + authorizationCode: code, + callbackState: state, + }); + } catch (error) { + logger.warn(`Failed to authorize MCP server ${userServer.server.name} for user ${session.user.id}:`, error); + try { + await provider.invalidateCredentials('verifier'); + } catch (cleanupError) { + logger.warn(`Failed to clear MCP OAuth verifier for user ${session.user.id}:`, cleanupError); + } + return redirectToSettingsError(reconnectMessage); + } + + // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. + try { + await provider.invalidateCredentials('verifier'); + } catch (cleanupError) { + logger.warn(`Failed to clear MCP OAuth verifier for user ${session.user.id}:`, cleanupError); + } if (result === 'AUTHORIZED') { - const displayName = userServer.name || userServer.server.serverUrl; + const displayName = userServer.server.name || userServer.server.serverUrl; logger.info(`Successfully authorized MCP server ${displayName} for user ${session.user.id}.`); settingsUrl.searchParams.set('status', 'connected'); settingsUrl.searchParams.set('server', displayName); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts new file mode 100644 index 000000000..6689585b4 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + hasEntitlement: vi.fn(), + mcpAuth: vi.fn(), + unsafePrisma: { + $transaction: vi.fn(), + }, +})); + +vi.mock('server-only', () => ({})); +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); +vi.mock('@/prisma', () => ({ + __unsafePrisma: mocks.unsafePrisma, +})); +vi.mock('@sourcebot/shared', () => ({ + env: { + AUTH_URL: 'https://sourcebot.example.com', + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 5000, + }, + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), + decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), +})); +vi.mock('@ai-sdk/mcp', () => ({ + auth: mocks.mcpAuth, +})); + +const { POST } = await import('./route'); + +function createRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serverId: 'server-1' }), + }); +} + +function createPrismaMock() { + return { + mcpServer: { + findFirst: vi.fn().mockResolvedValue({ + id: 'server-1', + serverUrl: 'https://mcp.linear.app/mcp', + }), + }, + userMcpServer: { + upsert: vi.fn().mockResolvedValue({ userId: 'user-1', serverId: 'server-1' }), + }, + }; +} + +function createTransactionMock() { + return { + $queryRaw: vi.fn().mockResolvedValue([{ id: 'server-1' }]), + mcpServer: { + findFirst: vi.fn(), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + userMcpServer: { + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); +}); + +describe('POST /api/ee/askmcp/connect', () => { + test('upserts a nameless user row and performs DCR-capable auth under a row lock', async () => { + const prisma = createPrismaMock(); + const tx = createTransactionMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + mocks.unsafePrisma.$transaction.mockImplementation(async (callback, _options) => callback(tx)); + mocks.mcpAuth.mockImplementation(async (provider, options) => { + expect('saveClientInformation' in provider).toBe(true); + expect(provider.saveClientInformation).toEqual(expect.any(Function)); + expect(options.fetchFn).toEqual(expect.any(Function)); + + await provider.saveClientInformation({ client_id: 'client-1' }); + provider.authorizationUrl = 'https://oauth.example.com/authorize'; + return 'REDIRECT'; + }); + + const response = await POST(createRequest()); + const body = await response.json(); + + expect(prisma.userMcpServer.upsert).toHaveBeenCalledWith({ + where: { + userId_serverId: { + userId: 'user-1', + serverId: 'server-1', + }, + }, + create: { + userId: 'user-1', + serverId: 'server-1', + }, + update: {}, + }); + expect(mocks.unsafePrisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { + maxWait: 10000, + timeout: 10000, + }, + ); + expect(tx.$queryRaw).toHaveBeenCalledOnce(); + expect(tx.mcpServer.updateMany).toHaveBeenCalledWith({ + where: { id: 'server-1', orgId: 1 }, + data: { clientInfo: 'encrypted:{"client_id":"client-1"}' }, + }); + expect(body).toEqual({ authorizationUrl: 'https://oauth.example.com/authorize' }); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index d072c9002..a2ff9521b 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -3,7 +3,7 @@ import { apiHandler } from '@/lib/apiHandler'; import { withAuth } from '@/middleware/withAuth'; import { sew } from '@/middleware/sew'; import { isServiceError } from '@/lib/utils'; -import { serviceErrorResponse, notFound, requestBodySchemaValidationError } from '@/lib/serviceError'; +import { serviceErrorResponse, notFound, requestBodySchemaValidationError, ServiceErrorException } from '@/lib/serviceError'; import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; import { NextRequest } from 'next/server'; import { z } from 'zod'; @@ -11,8 +11,26 @@ import { hasEntitlement } from '@/lib/entitlements'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import { ConnectMcpResponse } from "@/app/api/(server)/ee/askmcp/connect/types"; import { env } from "@sourcebot/shared"; +import { __unsafePrisma } from '@/prisma'; const bodySchema = z.object({ serverId: z.string() }); +const MCP_AUTH_FETCH_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 30000); +const MCP_AUTH_TRANSACTION_MAX_WAIT_MS = 10000; +const MCP_AUTH_TRANSACTION_TIMEOUT_MS = MCP_AUTH_FETCH_TIMEOUT_MS + 5000; + +function createTimeoutFetch(timeoutMs: number): typeof fetch { + return async (input, init) => { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal; + + return fetch(input, { + ...init, + signal, + }); + }; +} export const POST = apiHandler(async (request: NextRequest) => { if (!(await hasEntitlement('oauth'))) { @@ -30,44 +48,77 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = await sew(() => withAuth(async ({ user, org, prisma }) => { - const mcpServer = await prisma.mcpServer.findUnique({ + const mcpServer = await prisma.mcpServer.findFirst({ where: { id: parsed.data.serverId, orgId: org.id }, + select: { + id: true, + serverUrl: true, + }, }); if (!mcpServer) { return notFound('MCP server not found'); } - // Verify the user has added this server to their list. - const userServer = await prisma.userMcpServer.findUnique({ + await prisma.userMcpServer.upsert({ where: { userId_serverId: { userId: user.id, serverId: mcpServer.id, }, }, - select: { userId: true }, + create: { + userId: user.id, + serverId: mcpServer.id, + }, + update: {}, }); - if (!userServer) { - return notFound('MCP server not found'); - } - const provider = new PrismaOAuthClientProvider( - prisma, - mcpServer.id, - user.id, - `${env.AUTH_URL}/api/ee/askmcp/callback`, - ); + const connectResult = await __unsafePrisma.$transaction(async (tx) => { + const lockedRows = await tx.$queryRaw<{ id: string }[]>` + SELECT id + FROM "McpServer" + WHERE id = ${mcpServer.id} AND "orgId" = ${org.id} + FOR UPDATE + `; + + if (lockedRows.length === 0) { + throw new ServiceErrorException(notFound('MCP server not found')); + } + + const provider = new PrismaOAuthClientProvider({ + prisma: tx, + clientInvalidationPrisma: tx, + serverId: mcpServer.id, + orgId: org.id, + userId: user.id, + callbackUrl: `${env.AUTH_URL}/api/ee/askmcp/callback`, + allowClientRegistration: true, + }); - const result = await mcpAuth(provider, { - serverUrl: new URL(mcpServer.serverUrl), + const authResult = await mcpAuth(provider, { + serverUrl: new URL(mcpServer.serverUrl), + fetchFn: createTimeoutFetch(MCP_AUTH_FETCH_TIMEOUT_MS), + }); + + return { + authResult, + authorizationUrl: provider.authorizationUrl ?? null, + }; + }, { + maxWait: MCP_AUTH_TRANSACTION_MAX_WAIT_MS, + timeout: MCP_AUTH_TRANSACTION_TIMEOUT_MS, }); - if (result === 'AUTHORIZED') { + if (connectResult.authResult === 'AUTHORIZED') { // Already has valid tokens (e.g., refreshed) return { authorizationUrl: null } satisfies ConnectMcpResponse; } - return { authorizationUrl: provider.authorizationUrl! } satisfies ConnectMcpResponse; + if (!connectResult.authorizationUrl) { + throw new Error('MCP auth returned REDIRECT without an authorization URL'); + } + + return { authorizationUrl: connectResult.authorizationUrl } satisfies ConnectMcpResponse; }) ); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts new file mode 100644 index 000000000..5fe917f02 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + hasEntitlement: vi.fn(), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); +vi.mock('@sourcebot/shared', () => ({ + decryptOAuthToken: vi.fn((value: string) => value), +})); + +const { GET } = await import('./route'); + +function createRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/servers', { method: 'GET' }); +} + +function createPrismaMock() { + return { + mcpServer: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://mcp.linear.app/mcp', + }, + { + id: 'server-2', + name: 'Sentry', + sanitizedName: 'sentry', + serverUrl: 'https://mcp.sentry.dev/mcp', + }, + { + id: 'server-3', + name: 'GitHub', + sanitizedName: 'github', + serverUrl: 'https://api.githubcopilot.com/mcp', + }, + ]), + }, + userMcpServer: { + findMany: vi.fn().mockResolvedValue([ + { + serverId: 'server-1', + tokens: JSON.stringify({ access_token: 'token', token_type: 'Bearer' }), + tokensExpiresAt: null, + }, + { + serverId: 'server-3', + tokens: JSON.stringify({ access_token: 'expired-token', token_type: 'Bearer' }), + tokensExpiresAt: new Date('2020-01-01T00:00:00.000Z'), + }, + ]), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); +}); + +describe('GET /api/ee/askmcp/servers', () => { + test('lists org servers and merges only the caller token status', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(prisma.mcpServer.findMany).toHaveBeenCalledWith({ + where: { orgId: 1 }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + sanitizedName: true, + serverUrl: true, + }, + }); + expect(prisma.userMcpServer.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + select: { + serverId: true, + tokens: true, + tokensExpiresAt: true, + }, + }); + expect(body).toMatchObject([ + { + id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', + isConnected: true, + isAuthExpired: false, + }, + { + id: 'server-2', + name: 'Sentry', + sanitizedName: 'sentry', + isConnected: false, + isAuthExpired: false, + }, + { + id: 'server-3', + name: 'GitHub', + sanitizedName: 'github', + isConnected: false, + isAuthExpired: true, + }, + ]); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index fbefd686e..98802eec6 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -4,7 +4,6 @@ import { isServiceError } from '@/lib/utils'; import { withAuth } from '@/middleware/withAuth'; import { hasEntitlement } from '@/lib/entitlements'; import { decryptOAuthToken } from '@sourcebot/shared'; -import { sanitizeMcpServerName } from '@/ee/features/mcp/utils'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import type { OAuthTokens } from '@ai-sdk/mcp'; @@ -28,39 +27,44 @@ export const GET = apiHandler(async () => { ); } - const result = await withAuth(async ({ user, prisma }) => { - const userServers = await prisma.userMcpServer.findMany({ - where: { userId: user.id }, + const result = await withAuth(async ({ org, user, prisma }) => { + const orgServers = await prisma.mcpServer.findMany({ + where: { orgId: org.id }, orderBy: { createdAt: 'desc' }, select: { + id: true, name: true, + sanitizedName: true, + serverUrl: true, + }, + }); + + const userServers = await prisma.userMcpServer.findMany({ + where: { userId: user.id }, + select: { + serverId: true, tokens: true, tokensExpiresAt: true, - server: { - select: { - id: true, - serverUrl: true, - }, - }, }, }); + const userServerByServerId = new Map(userServers.map((us) => [us.serverId, us])); - return userServers.map((us): McpServerWithStatus => { - const sanitizedName = sanitizeMcpServerName(us.name); - const origin = new URL(us.server.serverUrl).origin; + return orgServers.map((server): McpServerWithStatus => { + const userServer = userServerByServerId.get(server.id); + const origin = new URL(server.serverUrl).origin; const faviconUrl = `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; let isConnected = false; let isAuthExpired = false; - if (us.tokens) { + if (userServer?.tokens) { try { - const decrypted = decryptOAuthToken(us.tokens); + const decrypted = decryptOAuthToken(userServer.tokens); if (decrypted) { const tokens: OAuthTokens = JSON.parse(decrypted); - if (tokens.refresh_token || !us.tokensExpiresAt) { + if (tokens.refresh_token || !userServer.tokensExpiresAt) { isConnected = true; - } else if (new Date() > us.tokensExpiresAt) { + } else if (new Date() > userServer.tokensExpiresAt) { isAuthExpired = true; } else { isConnected = true; @@ -72,10 +76,10 @@ export const GET = apiHandler(async () => { } return { - id: us.server.id, - name: us.name, - serverUrl: us.server.serverUrl, - sanitizedName, + id: server.id, + name: server.name, + serverUrl: server.serverUrl, + sanitizedName: server.sanitizedName, faviconUrl, isConnected, isAuthExpired, diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts new file mode 100644 index 000000000..6bd7b02a5 --- /dev/null +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { OrgRole } from '@sourcebot/db'; +import { ErrorCode } from '@/lib/errorCodes'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + unsafePrisma: { + mcpServer: { + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock('server-only', () => ({})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); +vi.mock('@/prisma', () => ({ + __unsafePrisma: mocks.unsafePrisma, +})); + +const { createMcpServer, deleteMcpServer } = await import('./actions'); + +function createPrismaMock() { + return { + mcpServer: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://mcp.linear.app/mcp', + }), + }, + }; +} + +function setAuthContext(role: OrgRole, prisma = createPrismaMock()) { + mocks.authContext = { + org: { id: 1 }, + role, + prisma, + }; + return prisma; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createMcpServer', () => { + test('owners add an org MCP server without dynamic client information', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + + const result = await createMcpServer(' Linear ', ' https://mcp.linear.app/mcp '); + + expect(result).toEqual({ + id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://mcp.linear.app/mcp', + }); + expect(prisma.mcpServer.create).toHaveBeenCalledWith({ + data: { + name: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://mcp.linear.app/mcp', + clientInfo: null, + orgId: 1, + }, + }); + }); + + test('members cannot add org MCP servers', async () => { + const prisma = setAuthContext(OrgRole.MEMBER); + + const result = await createMcpServer('Linear', 'https://mcp.linear.app/mcp'); + + expect(result).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + }); +}); + +describe('deleteMcpServer', () => { + test('owners delete through the narrowly scoped unsafe client', async () => { + setAuthContext(OrgRole.OWNER); + mocks.unsafePrisma.mcpServer.deleteMany.mockResolvedValue({ count: 1 }); + + await expect(deleteMcpServer('server-1')).resolves.toEqual({ success: true }); + expect(mocks.unsafePrisma.mcpServer.deleteMany).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + }, + }); + }); + + test('members cannot delete org MCP servers', async () => { + setAuthContext(OrgRole.MEMBER); + + const result = await deleteMcpServer('server-1'); + + expect(result).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(mocks.unsafePrisma.mcpServer.deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index 1dfcb03ee..8dc2dbc21 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -4,127 +4,105 @@ import { sew } from '@/middleware/sew'; import { ErrorCode } from '@/lib/errorCodes'; import { ServiceError } from '@/lib/serviceError'; import { withAuth } from '@/middleware/withAuth'; +import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; +import { __unsafePrisma } from '@/prisma'; +import { OrgRole } from '@sourcebot/db'; import { StatusCodes } from 'http-status-codes'; import { z } from 'zod'; import { sanitizeMcpServerName } from './utils'; export const createMcpServer = async (name: string, serverUrl: string) => sew(() => - withAuth(async ({ org, user, prisma }) => { - const urlResult = z.string().url().safeParse(serverUrl); - if (!urlResult.success || !serverUrl.startsWith('https://')) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Invalid server URL. Must be a valid HTTPS URL.', - } satisfies ServiceError; - } + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const displayName = name.trim(); + const normalizedServerUrl = serverUrl.trim(); + const urlResult = z.string().url().safeParse(normalizedServerUrl); + const protocol = urlResult.success ? new URL(normalizedServerUrl).protocol : undefined; + if (!urlResult.success || protocol !== 'https:') { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid server URL. Must be a valid HTTPS URL.', + } satisfies ServiceError; + } - const sanitizedName = sanitizeMcpServerName(name); - const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; - if (alphanumericCount < 3) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Server name must contain at least 3 alphanumeric characters.', - } satisfies ServiceError; - } + const sanitizedName = sanitizeMcpServerName(displayName); + const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; + if (alphanumericCount < 3) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Server name must contain at least 3 alphanumeric characters.', + } satisfies ServiceError; + } - // Upsert the McpServer record — reuse if the endpoint already exists for this org. - const mcpServer = await prisma.mcpServer.upsert({ - where: { - serverUrl_orgId: { - serverUrl, - orgId: org.id, + const existingServer = await prisma.mcpServer.findUnique({ + where: { + serverUrl_orgId: { + serverUrl: normalizedServerUrl, + orgId: org.id, + }, }, - }, - update: {}, - create: { - serverUrl, - orgId: org.id, - }, - }); + select: { id: true }, + }); + if (existingServer) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: `An MCP server with URL "${normalizedServerUrl}" already exists.`, + } satisfies ServiceError; + } - // Check if this user already has this server in their list. - const existingUserServer = await prisma.userMcpServer.findUnique({ - where: { - userId_serverId: { - userId: user.id, - serverId: mcpServer.id, + const existingName = await prisma.mcpServer.findFirst({ + where: { + orgId: org.id, + sanitizedName, }, - }, - select: { userId: true }, - }); + select: { id: true }, + }); + if (existingName) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: `An MCP server with a similar name already exists. Please choose a more distinct name.`, + } satisfies ServiceError; + } - if (existingUserServer) { - return { - statusCode: StatusCodes.CONFLICT, - errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: `You have already added an MCP server with URL "${serverUrl}".`, - } satisfies ServiceError; - } + const mcpServer = await prisma.mcpServer.create({ + data: { + name: displayName, + sanitizedName, + serverUrl: normalizedServerUrl, + clientInfo: null, + orgId: org.id, + }, + }); - // Ensure the sanitized name is unique within the user's own servers to prevent - // tool-name collisions (e.g. "My Server" and "My-Server" both become "my_server"). - const userServers = await prisma.userMcpServer.findMany({ - where: { userId: user.id }, - select: { name: true }, - }); - const nameCollision = userServers.some( - (s) => sanitizeMcpServerName(s.name) === sanitizedName - ); - if (nameCollision) { return { - statusCode: StatusCodes.CONFLICT, - errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: `You already have an MCP server with a similar name. Please choose a more distinct name.`, - } satisfies ServiceError; - } - - await prisma.userMcpServer.create({ - data: { - userId: user.id, - serverId: mcpServer.id, - name, - }, - }); - - return { - id: mcpServer.id, - name, - serverUrl: mcpServer.serverUrl, - }; - })); + id: mcpServer.id, + name: displayName, + sanitizedName, + serverUrl: mcpServer.serverUrl, + }; + }))); export const deleteMcpServer = async (serverId: string) => sew(() => - withAuth(async ({ user, prisma }) => { - const userServer = await prisma.userMcpServer.findUnique({ - where: { - userId_serverId: { - userId: user.id, - serverId, + withAuth(async ({ org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const result = await __unsafePrisma.mcpServer.deleteMany({ + where: { + id: serverId, + orgId: org.id, }, - }, - select: { userId: true }, - }); + }); - if (!userServer) { - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, - message: 'MCP server not found', - } satisfies ServiceError; - } - - // Delete the user's connection row. The McpServer row stays because other - // users may reference the same endpoint. - await prisma.userMcpServer.delete({ - where: { - userId_serverId: { - userId: user.id, - serverId, - }, - }, - }); + if (result.count === 0) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + message: 'MCP server not found', + } satisfies ServiceError; + } - return { success: true }; - })); + return { success: true }; + }))); diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts index 8dc21a9c6..9d8f999e6 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -44,11 +44,12 @@ function makeUserServer(overrides: { return { serverId: 'srv-1', userId: 'user-1', - name: 'MyServer', tokens: JSON.stringify(overrides.tokens ?? TOKEN_NO_REFRESH), tokensExpiresAt: overrides.tokensExpiresAt ?? null, server: { orgId: overrides.orgId ?? 1, + name: 'MyServer', + sanitizedName: 'myserver', serverUrl: 'https://example.com/mcp', }, }; @@ -122,6 +123,7 @@ describe('getConnectedMcpClients', () => { expect(result[0]).toMatchObject({ serverId: 'srv-1', serverName: 'MyServer', + sanitizedName: 'myserver', serverUrl: 'https://example.com/mcp', }); }); diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts index cbc5c2c1d..98c8e6428 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -9,6 +9,7 @@ const logger = createLogger('mcp-client-factory'); export interface McpToolSet { serverId: string; serverName: string; + sanitizedName: string; serverUrl: string; transport: StreamableHTTPClientTransport; } @@ -36,16 +37,20 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin where: { userId, tokens: { not: null }, - server: { orgId }, + server: { + orgId, + clientInfo: { not: null }, + }, }, select: { serverId: true, - name: true, tokens: true, tokensExpiresAt: true, server: { select: { orgId: true, + name: true, + sanitizedName: true, serverUrl: true, }, }, @@ -60,7 +65,7 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin continue; } - const serverName = userServer.name; + const serverName = userServer.server.name; try { const decrypted = decryptOAuthToken(userServer.tokens); @@ -76,12 +81,13 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin continue; } - const provider = new PrismaOAuthClientProvider( + const provider = new PrismaOAuthClientProvider({ prisma, - userServer.serverId, + serverId: userServer.serverId, + orgId, userId, - `${env.AUTH_URL}/api/ee/askmcp/callback`, - ); + callbackUrl: `${env.AUTH_URL}/api/ee/askmcp/callback`, + }); const transport = new StreamableHTTPClientTransport( new URL(userServer.server.serverUrl), @@ -91,6 +97,7 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin clients.push({ serverId: userServer.serverId, serverName, + sanitizedName: userServer.server.sanitizedName, serverUrl: userServer.server.serverUrl, transport, }); diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts index d49f56986..ae41bf8e6 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts @@ -55,6 +55,7 @@ function createMockMcpClient(toolDefs: MockToolDef[]) { function createMockClient(overrides: Partial & { serverName: string }): McpToolSet { return { serverId: 'server-id', + sanitizedName: overrides.serverName.toLowerCase(), serverUrl: `https://${overrides.serverName.toLowerCase()}.example.com/mcp`, transport: {} as McpToolSet['transport'], ...overrides, diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index 91a235b8b..2ad2277b8 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -1,7 +1,6 @@ import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'; import { McpToolSet } from './mcpClientFactory'; import { createLogger, env } from '@sourcebot/shared'; -import { sanitizeMcpServerName } from './utils'; import Ajv from 'ajv'; import { jsonSchema, ToolExecutionOptions } from 'ai'; import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; @@ -35,7 +34,7 @@ export async function getMcpTools(clients: McpToolSet[]): Promise ({})); +vi.mock('@/prisma', () => ({ + __unsafePrisma: { + mcpServer: {}, + userMcpServer: {}, + }, +})); +vi.mock('@sourcebot/shared', () => ({ + encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), + decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), +})); + +const { + PrismaOAuthClientProvider, + clearMcpServerClientCredentialsForObservedClient, +} = await import('./prismaOAuthClientProvider'); + +function createPrismaMock() { + return { + mcpServer: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + userMcpServer: { + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }; +} + +function createProvider(prisma = createPrismaMock(), allowClientRegistration = false) { + return new PrismaOAuthClientProvider({ + prisma: prisma as never, + clientInvalidationPrisma: prisma as never, + serverId: 'server-1', + orgId: 1, + userId: 'user-1', + callbackUrl: 'https://sourcebot.example.com/api/ee/askmcp/callback', + allowClientRegistration, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('PrismaOAuthClientProvider modes', () => { + test('connect-mode provider exposes saveClientInformation', () => { + const provider = createProvider(createPrismaMock(), true); + + expect('saveClientInformation' in provider).toBe(true); + expect(provider.saveClientInformation).toEqual(expect.any(Function)); + }); + + test('runtime and callback providers omit saveClientInformation', () => { + const provider = createProvider(); + + expect('saveClientInformation' in provider).toBe(false); + expect(provider.saveClientInformation).toBeUndefined(); + }); +}); + +describe('clearMcpServerClientCredentialsForObservedClient', () => { + test('matching observed clientInfo clears org clientInfo and all server tokens', async () => { + const prisma = createPrismaMock(); + prisma.mcpServer.updateMany.mockResolvedValue({ count: 1 }); + prisma.userMcpServer.updateMany.mockResolvedValue({ count: 2 }); + + const didClear = await clearMcpServerClientCredentialsForObservedClient({ + prisma: prisma as never, + serverId: 'server-1', + orgId: 1, + observedClientInfo: 'encrypted-client-info', + }); + + expect(didClear).toBe(true); + expect(prisma.mcpServer.updateMany).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + clientInfo: 'encrypted-client-info', + }, + data: { clientInfo: null }, + }); + expect(prisma.userMcpServer.updateMany).toHaveBeenCalledWith({ + where: { + serverId: 'server-1', + server: { orgId: 1 }, + }, + data: { + tokens: null, + tokensExpiresAt: null, + }, + }); + }); + + test('stale observed clientInfo clears neither org clientInfo nor tokens', async () => { + const prisma = createPrismaMock(); + prisma.mcpServer.updateMany.mockResolvedValue({ count: 0 }); + + const didClear = await clearMcpServerClientCredentialsForObservedClient({ + prisma: prisma as never, + serverId: 'server-1', + orgId: 1, + observedClientInfo: 'stale-client-info', + }); + + expect(didClear).toBe(false); + expect(prisma.mcpServer.updateMany).toHaveBeenCalledOnce(); + expect(prisma.userMcpServer.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index 4e79a6704..9d6f12552 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -7,24 +7,121 @@ import type { } from '@ai-sdk/mcp'; import type { PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; +import { __unsafePrisma } from '@/prisma'; + +type McpOAuthPrismaClient = Pick; + +interface PrismaOAuthClientProviderOptions { + prisma: McpOAuthPrismaClient; + serverId: string; + orgId: number; + userId: string; + callbackUrl: string; + allowClientRegistration?: boolean; + clientInvalidationPrisma?: McpOAuthPrismaClient; +} + +export interface ClearMcpServerClientCredentialsOptions { + prisma?: McpOAuthPrismaClient; + serverId: string; + orgId: number; + observedClientInfo: string | undefined; +} + +export async function clearMcpServerClientCredentialsForObservedClient({ + prisma = __unsafePrisma, + serverId, + orgId, + observedClientInfo, +}: ClearMcpServerClientCredentialsOptions): Promise { + if (!observedClientInfo) { + return false; + } + + const result = await prisma.mcpServer.updateMany({ + where: { + id: serverId, + orgId, + clientInfo: observedClientInfo, + }, + data: { clientInfo: null }, + }); + + if (result.count === 0) { + return false; + } + + await prisma.userMcpServer.updateMany({ + where: { + serverId, + server: { orgId }, + }, + data: { + tokens: null, + tokensExpiresAt: null, + }, + }); + + return true; +} /** * Prisma-backed OAuthClientProvider for connecting to external MCP servers. * - * Stores dynamic client registration (client_id/secret) on McpServer (per-org), - * and per-user tokens + ephemeral PKCE state on UserMcpServer. + * Stores dynamic client registration on McpServer (per-org), and per-user + * tokens + ephemeral PKCE state on UserMcpServer. */ export class PrismaOAuthClientProvider implements OAuthClientProvider { - constructor( - private readonly prisma: PrismaClient, - private readonly serverId: string, - private readonly userId: string, - private readonly callbackUrl: string, - ) {} + private readonly prisma: McpOAuthPrismaClient; + private readonly clientInvalidationPrisma: McpOAuthPrismaClient; + private readonly serverId: string; + private readonly orgId: number; + private readonly userId: string; + private readonly callbackUrl: string; + private observedClientInfo: string | undefined; /** Populated by redirectToAuthorization — read after auth() returns 'REDIRECT'. */ public authorizationUrl: string | undefined; + /** Only present in connect mode. If absent, the SDK cannot perform DCR. */ + declare saveClientInformation?: (info: OAuthClientInformation) => Promise; + + constructor({ + prisma, + serverId, + orgId, + userId, + callbackUrl, + allowClientRegistration = false, + clientInvalidationPrisma = __unsafePrisma, + }: PrismaOAuthClientProviderOptions) { + this.prisma = prisma; + this.clientInvalidationPrisma = clientInvalidationPrisma; + this.serverId = serverId; + this.orgId = orgId; + this.userId = userId; + this.callbackUrl = callbackUrl; + + if (allowClientRegistration) { + this.saveClientInformation = async (info: OAuthClientInformation) => { + const encrypted = encryptOAuthToken(JSON.stringify(info)); + if (!encrypted) { + throw new Error('Failed to encrypt OAuth client information'); + } + + const result = await this.prisma.mcpServer.updateMany({ + where: { id: this.serverId, orgId: this.orgId }, + data: { clientInfo: encrypted }, + }); + if (result.count === 0) { + throw new Error('MCP server not found'); + } + + this.observedClientInfo = encrypted; + }; + } + } + get redirectUrl(): string | URL { return this.callbackUrl; } @@ -40,26 +137,20 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async clientInformation(): Promise { - const server = await this.prisma.mcpServer.findUnique({ - where: { id: this.serverId }, + const server = await this.prisma.mcpServer.findFirst({ + where: { id: this.serverId, orgId: this.orgId }, select: { clientInfo: true }, }); if (!server?.clientInfo) { + this.observedClientInfo = undefined; return undefined; } + this.observedClientInfo = server.clientInfo; const decrypted = decryptOAuthToken(server.clientInfo); return decrypted ? JSON.parse(decrypted) : undefined; } - async saveClientInformation(info: OAuthClientInformation): Promise { - const encrypted = encryptOAuthToken(JSON.stringify(info)); - await this.prisma.mcpServer.update({ - where: { id: this.serverId }, - data: { clientInfo: encrypted }, - }); - } - async tokens(): Promise { const userServer = await this.getUserServer(); if (!userServer?.tokens) { @@ -72,13 +163,14 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { async saveTokens(tokens: OAuthTokens): Promise { const encrypted = encryptOAuthToken(JSON.stringify(tokens)); + if (!encrypted) { + throw new Error('Failed to encrypt OAuth tokens'); + } + const tokensExpiresAt = tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null; - await this.prisma.userMcpServer.update({ - where: { userId_serverId: { userId: this.userId, serverId: this.serverId } }, - data: { tokens: encrypted, tokensExpiresAt }, - }); + await this.updateUserServer({ tokens: encrypted, tokensExpiresAt }); } async codeVerifier(): Promise { @@ -115,27 +207,30 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { url.searchParams.set('prompt', 'consent'); } - // Clear any stale tokens from the database. This is called when the SDK determines - // that existing tokens are no longer valid (e.g., the access token expired and the - // refresh token was revoked). Clearing them ensures the UI reflects "not connected" - // so the user knows to re-authenticate, rather than staying stuck in a state where - // the server appears connected but all tool calls fail. + // Clear stale tokens before starting a new authorization flow so the UI reflects + // that the user needs to complete OAuth again. await this.invalidateCredentials('tokens'); this.authorizationUrl = url.toString(); } async invalidateCredentials( - scope: 'all' | 'client' | 'tokens' | 'verifier', + scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', ): Promise { + if (scope === 'discovery') { + return; + } + if (scope === 'all' || scope === 'client') { - await this.prisma.mcpServer.update({ - where: { id: this.serverId }, - data: { clientInfo: null }, + await clearMcpServerClientCredentialsForObservedClient({ + prisma: this.clientInvalidationPrisma, + serverId: this.serverId, + orgId: this.orgId, + observedClientInfo: this.observedClientInfo, }); } - if (scope === 'all' || scope === 'tokens') { + if (scope === 'tokens') { await this.updateUserServer({ tokens: null, tokensExpiresAt: null }); } diff --git a/packages/web/src/features/mcp/prismaScope.test.ts b/packages/web/src/features/mcp/prismaScope.test.ts index c5092acbb..4b86264db 100644 --- a/packages/web/src/features/mcp/prismaScope.test.ts +++ b/packages/web/src/features/mcp/prismaScope.test.ts @@ -106,11 +106,11 @@ describe('getMcpPrismaQueryExtension', () => { const query = resetQuery(); await expect(anonymousExtension.userMcpServer.create({ - args: { data: { userId: 'user-1', serverId: 'server-1', name: 'Linear' } }, + args: { data: { userId: 'user-1', serverId: 'server-1' } }, query, })).rejects.toThrow('requires an authenticated user'); await expect(extension.userMcpServer.create({ - args: { data: { userId: 'user-2', serverId: 'server-1', name: 'Linear' } }, + args: { data: { userId: 'user-2', serverId: 'server-1' } }, query, })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); @@ -123,7 +123,6 @@ describe('getMcpPrismaQueryExtension', () => { data: { user: { connect: { id: 'user-1' } }, server: { connect: { id: 'server-1' } }, - name: 'Linear', }, }; @@ -142,7 +141,6 @@ describe('getMcpPrismaQueryExtension', () => { data: { user: { connect: { id: 'user-2' } }, server: { connect: { id: 'server-1' } }, - name: 'Linear', }, }, query, @@ -152,7 +150,6 @@ describe('getMcpPrismaQueryExtension', () => { data: { user: { create: { id: 'user-1', email: 'test@example.com' } }, server: { connect: { id: 'server-1' } }, - name: 'Linear', }, }, query, @@ -168,7 +165,7 @@ describe('getMcpPrismaQueryExtension', () => { await expect(extension.userMcpServer.update({ args: { where: { userId_serverId: { userId: 'user-2', serverId: 'server-1' } }, - data: { name: 'Linear' }, + data: { state: null }, }, query, })).rejects.toThrow('cannot access UserMcpServer rows for another user'); @@ -200,7 +197,7 @@ describe('getMcpPrismaQueryExtension', () => { await expect(extension.userMcpServer.upsert({ args: { where: { userId_serverId: { userId: 'user-1', serverId: 'server-1' } }, - create: { userId: 'user-1', serverId: 'server-1', name: 'Linear' }, + create: { userId: 'user-1', serverId: 'server-1' }, update: { user: { connect: { id: 'user-2' } } }, }, query: resetQuery(), @@ -239,7 +236,7 @@ describe('getMcpPrismaQueryExtension', () => { const extension = getMcpPrismaQueryExtension(user); await expect(extension.userMcpServer.createManyAndReturn({ - args: { data: { userId: 'user-2', serverId: 'server-1', name: 'Linear' } }, + args: { data: { userId: 'user-2', serverId: 'server-1' } }, query: resetQuery(), })).rejects.toThrow('must create UserMcpServer rows for the authenticated user'); await expect(extension.userMcpServer.updateManyAndReturn({ @@ -285,7 +282,7 @@ describe('getMcpPrismaQueryExtension', () => { 'update', { where: { id: 'server-1' }, - data: { userMcpServers: { create: { userId: 'user-1', name: 'Linear' } } }, + data: { userMcpServers: { create: { userId: 'user-1' } } }, }, query, )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); @@ -319,7 +316,7 @@ describe('getMcpPrismaQueryExtension', () => { 'update', { where: { id: 'user-1' }, - data: { userMcpServers: { create: { serverId: 'server-1', name: 'Linear' } } }, + data: { userMcpServers: { create: { serverId: 'server-1' } } }, }, query, )).rejects.toThrow('cannot access UserMcpServer rows through a parent relation'); @@ -354,9 +351,11 @@ describe('getMcpPrismaQueryExtension', () => { data: { mcpServers: { create: { + name: 'Linear', + sanitizedName: 'linear', serverUrl: 'https://mcp.linear.app/mcp', userMcpServers: { - create: { userId: 'user-1', name: 'Linear' }, + create: { userId: 'user-1' }, }, }, }, From acda402a4ecb1b897d75f166eb895a530ce85eb3 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 15:15:58 -0700 Subject: [PATCH 06/40] feat(web): add workspace MCP configuration --- .../migration.sql | 1 + packages/db/prisma/schema.prisma | 1 + .../components/settingsSidebar/nav.tsx | 2 + .../web/src/app/(app)/settings/layout.tsx | 7 + .../mcpConfiguration/mcpConfigurationPage.tsx | 262 ++++++++++++++++++ .../mcpConfigurationUnavailableMessage.tsx | 29 ++ .../settings/mcpConfiguration/page.test.tsx | 45 +++ .../(app)/settings/mcpConfiguration/page.tsx | 13 + .../mcpServers/mcpServersPage.test.tsx | 25 ++ .../settings/mcpServers/mcpServersPage.tsx | 229 ++++----------- packages/web/src/app/api/(client)/client.ts | 12 + .../ee/askmcp/configuration/route.test.ts | 201 ++++++++++++++ .../(server)/ee/askmcp/configuration/route.ts | 74 +++++ .../api/(server)/ee/askmcp/servers/route.ts | 9 +- .../web/src/ee/features/mcp/actions.test.ts | 31 +++ packages/web/src/ee/features/mcp/actions.ts | 10 + packages/web/src/ee/features/mcp/errors.ts | 10 + .../web/src/ee/features/mcp/queryKeys.test.ts | 16 ++ packages/web/src/ee/features/mcp/queryKeys.ts | 13 + packages/web/src/ee/features/mcp/types.ts | 16 ++ .../web/src/ee/features/mcp/utils.test.ts | 12 +- packages/web/src/ee/features/mcp/utils.ts | 11 +- .../components/chatBox/chatBoxPlusButton.tsx | 3 +- 23 files changed, 847 insertions(+), 185 deletions(-) create mode 100644 packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql create mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx create mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx create mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx create mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx create mode 100644 packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts create mode 100644 packages/web/src/ee/features/mcp/errors.ts create mode 100644 packages/web/src/ee/features/mcp/queryKeys.test.ts create mode 100644 packages/web/src/ee/features/mcp/queryKeys.ts create mode 100644 packages/web/src/ee/features/mcp/types.ts diff --git a/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql b/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql new file mode 100644 index 000000000..d171bca2c --- /dev/null +++ b/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 1f7b97bec..c0fe9eb10 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -698,5 +698,6 @@ model UserMcpServer { updatedAt DateTime @updatedAt @@id([userId, serverId]) + @@index([serverId]) @@index([state]) } diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 8eb817e55..306c5702e 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -18,6 +18,7 @@ import { type LucideIcon, PlugIcon, ScrollTextIcon, + ServerIcon, Settings2Icon, ShieldIcon, UserIcon, @@ -37,6 +38,7 @@ const iconMap = { "plug": PlugIcon, "chart-area": ChartAreaIcon, "scroll-text": ScrollTextIcon, + "server": ServerIcon, "settings": Settings2Icon, "user": UserIcon, "mcp": VscMcp, diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 018a6db91..ad33603c3 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -121,6 +121,13 @@ export const getSidebarNavGroups = async () => icon: "chart-area" as const, requiredEntitlement: 'analytics' }, + ...(await hasEntitlement("oauth") ? [ + { + title: "MCP Configuration", + href: `/settings/mcpConfiguration`, + icon: "server" as const, + } + ] : []), { title: "License", href: `/settings/license`, diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx new file mode 100644 index 000000000..bf5eb3dff --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState } from "react"; +import { getMcpConfiguration } from "@/app/api/(client)/client"; +import { useToast } from "@/components/hooks/use-toast"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { isServiceError } from "@/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MinusIcon, PlusIcon, ServerIcon } from "lucide-react"; + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +export function McpConfigurationPage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newServerName, setNewServerName] = useState(""); + const [newServerUrl, setNewServerUrl] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [deletingServerId, setDeletingServerId] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: mcpQueryKeys.configuration, + queryFn: async () => { + const result = await getMcpConfiguration(); + if (isServiceError(result)) { + throw new Error(result.message); + } + return result; + }, + }); + + const servers = data?.servers ?? []; + const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; + + const handleCloseCreateDialog = () => { + setIsCreateDialogOpen(false); + setNewServerName(""); + setNewServerUrl(""); + }; + + const handleCreate = async () => { + if (!newServerName.trim() || !newServerUrl.trim()) { + toast({ title: "Error", description: "Name and server URL are required", variant: "destructive" }); + return; + } + + setIsCreating(true); + try { + const result = await createMcpServer(newServerName.trim(), newServerUrl.trim()); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + handleCloseCreateDialog(); + } catch (error) { + toast({ title: "Error", description: `Failed to add MCP server: ${error}`, variant: "destructive" }); + } finally { + setIsCreating(false); + } + }; + + const handleDelete = async (serverId: string) => { + setDeletingServerId(serverId); + try { + const result = await deleteMcpServer(serverId); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to delete MCP server: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + } catch (error) { + toast({ title: "Error", description: `Failed to delete MCP server: ${error}`, variant: "destructive" }); + } finally { + setDeletingServerId(null); + } + }; + + if (isError) { + return
Error loading MCP configuration
; + } + + return ( +
+
+

MCP Configuration

+

+ Configure the MCP servers that workspace members can connect to. +

+
+ + + +
+
+

Saved MCP connections

+

+ Current workspace members with saved MCP server credentials. +

+
+ {isLoading ? ( + + ) : ( +

+ {totalSavedConnectionCount} {pluralize(totalSavedConnectionCount, "connection")} +

+ )} +
+
+
+

Allowed MCP servers

+

+ Sourcebot Ask can use only workspace-approved MCP servers. +

+
+

Only approved servers

+
+
+
+ + + +
+ {isLoading ? "Allowed servers" : `${servers.length} allowed ${pluralize(servers.length, "server")}`} + Approve server URLs that workspace members can connect to. +
+ + + + + + + Add MCP Server + +
+
+ + setNewServerName(event.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(event.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : servers.length === 0 ? ( +
+
+ +
+

No MCP servers configured yet

+

+ Add a workspace-approved MCP server so members can connect it to Ask Sourcebot. +

+
+ ) : ( +
+ {servers.map((server) => ( +
+
+ +
+
+

{server.name || server.serverUrl}

+

{server.serverUrl}

+
+

+ {server.savedConnectionCount} {pluralize(server.savedConnectionCount, "saved connection")} +

+ + + + + + + Delete MCP Server + + Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this server. + + + + Cancel + handleDelete(server.id)} + disabled={deletingServerId === server.id} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deletingServerId === server.id ? "Deleting..." : "Delete"} + + + + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx new file mode 100644 index 000000000..6ef7ded41 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx @@ -0,0 +1,29 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ServerIcon } from "lucide-react"; + +export function McpConfigurationUnavailableMessage() { + return ( +
+ + +
+
+ +
+
+ + MCP Configuration Is Unavailable + + + OAuth-backed MCP servers are not supported on this Sourcebot instance. + +
+ +

+ Use Sourcebot API keys for MCP access on this deployment. +

+
+
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx new file mode 100644 index 000000000..7fe9219bd --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import type React from 'react'; + +const mocks = vi.hoisted(() => ({ + hasEntitlement: vi.fn(), +})); + +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/middleware/authenticatedPage', () => ({ + authenticatedPage: vi.fn((page: () => Promise) => page), +})); +vi.mock('./mcpConfigurationPage', () => ({ + McpConfigurationPage: () =>
MCP configuration client
, +})); + +const { default: Page } = await import('./page'); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); +}); + +afterEach(() => { + cleanup(); +}); + +describe('MCP configuration settings page', () => { + test('renders the client configuration page when OAuth is available', async () => { + render(await Page({})); + + expect(screen.getByText('MCP configuration client')).toBeTruthy(); + }); + + test('renders an unavailable message when OAuth is not available', async () => { + mocks.hasEntitlement.mockResolvedValue(false); + + render(await Page({})); + + expect(screen.getByText('MCP Configuration Is Unavailable')).toBeTruthy(); + expect(screen.queryByText('MCP configuration client')).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx new file mode 100644 index 000000000..25e0a23fc --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx @@ -0,0 +1,13 @@ +import { hasEntitlement } from "@/lib/entitlements"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { OrgRole } from "@sourcebot/db"; +import { McpConfigurationPage } from "./mcpConfigurationPage"; +import { McpConfigurationUnavailableMessage } from "./mcpConfigurationUnavailableMessage"; + +export default authenticatedPage(async () => { + if (!(await hasEntitlement("oauth"))) { + return ; + } + + return ; +}, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx new file mode 100644 index 000000000..6f9221d80 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { McpServersEmptyState } from './mcpServersPage'; + +afterEach(() => { + cleanup(); +}); + +describe('McpServersEmptyState', () => { + test('points owners to workspace MCP configuration', () => { + render(); + + expect(screen.getByText('No MCP servers configured yet')).toBeTruthy(); + expect(screen.getByText(/Go to Workspace MCP Configuration/)).toBeTruthy(); + expect(screen.getByRole('link', { name: /Open MCP Configuration/ }).getAttribute('href')).toBe('/settings/mcpConfiguration'); + }); + + test('tells members to contact an admin', () => { + render(); + + expect(screen.getByText('No MCP servers available')).toBeTruthy(); + expect(screen.getByText(/Contact your workspace admin/)).toBeTruthy(); + expect(screen.queryByRole('link', { name: /Open MCP Configuration/ })).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx index 5009511df..f34bcca52 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx @@ -1,26 +1,18 @@ 'use client'; -import { useEffect, useRef, useState } from "react"; -import { useToast } from "@/components/hooks/use-toast"; -import { isServiceError } from "@/lib/utils"; -import { createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { useEffect, useRef } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { Settings2Icon, ServerIcon } from "lucide-react"; import { getMcpServersWithStatus } from "@/app/api/(client)/client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; +import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; -import { - Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, -} from "@/components/ui/dialog"; -import { - AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; -import { Loader2, Plus, Server, Trash2 } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { isServiceError } from "@/lib/utils"; function clearCallbackParams() { const url = new URL(window.location.href); @@ -37,6 +29,34 @@ interface McpServersPageProps { canManageMcpServers: boolean; } +export function McpServersEmptyState({ canManageMcpServers }: { canManageMcpServers: boolean }) { + return ( + + +
+ +
+

+ {canManageMcpServers ? "No MCP servers configured yet" : "No MCP servers available"} +

+

+ {canManageMcpServers + ? "Go to Workspace MCP Configuration to add servers before connecting them to Ask Sourcebot." + : "No MCP servers have been approved for this workspace yet. Contact your workspace admin."} +

+ {canManageMcpServers && ( + + )} +
+
+ ); +} + export function McpServersPage({ callbackStatus, callbackServer, callbackMessage, canManageMcpServers }: McpServersPageProps) { const { toast } = useToast(); const didHandleCallbackRef = useRef(false); @@ -56,10 +76,8 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage } }, [callbackStatus, callbackServer, callbackMessage, toast]); - const queryClient = useQueryClient(); - const { data: servers = [], isLoading, isError } = useQuery({ - queryKey: ['mcpServersWithStatus'], + queryKey: mcpQueryKeys.serversWithStatus, queryFn: async () => { const result = await getMcpServersWithStatus(); if (isServiceError(result)) { @@ -69,125 +87,23 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage }, }); - // Create dialog state - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [newServerName, setNewServerName] = useState(""); - const [newServerUrl, setNewServerUrl] = useState(""); - const [isCreating, setIsCreating] = useState(false); - - // Delete state - const [deletingServerId, setDeletingServerId] = useState(null); - - const handleCreate = async () => { - if (!newServerUrl.trim()) { - toast({ title: "Error", description: "Server URL is required", variant: "destructive" }); - return; - } - - setIsCreating(true); - try { - const result = await createMcpServer(newServerName.trim(), newServerUrl.trim()); - if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); - return; - } - await queryClient.invalidateQueries({ queryKey: ['mcpServersWithStatus'] }); - handleCloseCreateDialog(); - } catch (e) { - toast({ title: "Error", description: `Failed to add MCP server: ${e}`, variant: "destructive" }); - } finally { - setIsCreating(false); - } - }; - - const handleCloseCreateDialog = () => { - setIsCreateDialogOpen(false); - setNewServerName(""); - setNewServerUrl(""); - }; - - const handleDelete = async (serverId: string) => { - setDeletingServerId(serverId); - try { - const result = await deleteMcpServer(serverId); - if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to delete: ${result.message}`, variant: "destructive" }); - return; - } - await queryClient.invalidateQueries({ queryKey: ['mcpServersWithStatus'] }); - } catch (e) { - toast({ title: "Error", description: `Failed to delete MCP server: ${e}`, variant: "destructive" }); - } finally { - setDeletingServerId(null); - } - }; - if (isError) { return
Error loading MCP servers
; } return (
- {/* Header + Add button */} -
-
-

MCP Servers

-

- {canManageMcpServers - ? "Approve external MCP servers for your workspace." - : "Connect to workspace-approved MCP servers to use them with Ask Sourcebot."} -

-
- - {canManageMcpServers && ( - - - - - - - Add MCP Server - -
-
- - setNewServerName(e.target.value)} - placeholder="e.g. Linear" - /> -
-
- - setNewServerUrl(e.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> -
-
- - - - -
-
- )} +
+

MCP Servers

+

+ Connect to workspace-approved MCP servers to use them with Ask Sourcebot. +

- {/* Server list */} {isLoading ? (
- {Array.from({ length: 2 }).map((_, i) => ( - + {Array.from({ length: 2 }).map((_, index) => ( + @@ -199,63 +115,20 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage ))}
) : servers.length === 0 ? ( - - -
- -
-

No MCP servers yet

-

- {canManageMcpServers - ? "Add an MCP server above to make it available to workspace members." - : "No MCP servers have been approved for this workspace yet."} -

-
-
+ ) : (
{servers.map((server) => (
-
- -
- {server.name || server.serverUrl} - {server.serverUrl} +
+ +
+ {server.name || server.serverUrl} + {server.serverUrl}
- {canManageMcpServers && ( - - - - - - - Delete MCP Server - - Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this server. - - - - Cancel - handleDelete(server.id)} - disabled={deletingServerId === server.id} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deletingServerId === server.id ? "Deleting..." : "Delete"} - - - - - )}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 41db4e017..072b3aceb 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -33,6 +33,7 @@ import type { import { OffersResponse } from "@/ee/features/lighthouse/types"; import { ConnectMcpResponse } from "../(server)/ee/askmcp/connect/types"; import type { GetMcpServersResponse } from "../(server)/ee/askmcp/servers/route"; +import type { GetMcpConfigurationResponse } from "@/ee/features/mcp/types"; export const search = async (body: SearchRequest): Promise => { const result = await fetch("/api/search", { @@ -272,3 +273,14 @@ export const getMcpServersWithStatus = async (): Promise => { + const result = await fetch('/api/ee/askmcp/configuration', { + method: 'GET', + headers: { + 'X-Sourcebot-Client-Source': 'sourcebot-web-client', + }, + }).then(response => response.json()); + + return result as GetMcpConfigurationResponse | ServiceError; +} diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts new file mode 100644 index 000000000..52f088b58 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { OrgRole } from '@sourcebot/db'; +import { ErrorCode } from '@/lib/errorCodes'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + hasEntitlement: vi.fn(), + withAuth: vi.fn(), + unsafePrisma: { + userMcpServer: { + groupBy: vi.fn(), + }, + }, +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: mocks.withAuth, +})); +vi.mock('@/prisma', () => ({ + __unsafePrisma: mocks.unsafePrisma, +})); + +const { GET } = await import('./route'); + +function createRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/configuration', { method: 'GET' }); +} + +function createPrismaMock() { + return { + mcpServer: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://mcp.linear.app/mcp', + }, + { + id: 'server-2', + name: 'Sentry', + sanitizedName: 'sentry', + serverUrl: 'https://mcp.sentry.dev/mcp', + }, + ]), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); + mocks.withAuth.mockImplementation((callback: (context: unknown) => unknown) => callback(mocks.authContext)); + mocks.unsafePrisma.userMcpServer.groupBy.mockResolvedValue([ + { + serverId: 'server-1', + _count: { _all: 2 }, + }, + ]); +}); + +describe('GET /api/ee/askmcp/configuration', () => { + test('lists approved servers with current-member saved connection counts', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 1 }, + role: OrgRole.OWNER, + prisma, + }; + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(prisma.mcpServer.findMany).toHaveBeenCalledWith({ + where: { orgId: 1 }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + sanitizedName: true, + serverUrl: true, + }, + }); + expect(mocks.unsafePrisma.userMcpServer.groupBy).toHaveBeenCalledWith({ + by: ['serverId'], + where: { + serverId: { in: ['server-1', 'server-2'] }, + tokens: { not: null }, + server: { orgId: 1 }, + user: { + orgs: { + some: { orgId: 1 }, + }, + }, + }, + _count: { _all: true }, + }); + expect(body).toMatchObject({ + totalSavedConnectionCount: 2, + allowedMode: 'approved_only', + servers: [ + { + id: 'server-1', + name: 'Linear', + savedConnectionCount: 2, + }, + { + id: 'server-2', + name: 'Sentry', + savedConnectionCount: 0, + }, + ], + }); + }); + + test('rejects non-owners before the unsafe aggregate query', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 1 }, + role: OrgRole.MEMBER, + prisma, + }; + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + }); + + test('rejects unauthenticated callers before checking OAuth entitlement', async () => { + mocks.withAuth.mockResolvedValue({ + statusCode: 401, + errorCode: ErrorCode.NOT_AUTHENTICATED, + message: 'Not authenticated', + }); + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toMatchObject({ + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + }); + + test('rejects entitled owners when OAuth is unsupported before data work', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 1 }, + role: OrgRole.OWNER, + prisma, + }; + mocks.hasEntitlement.mockResolvedValue(false); + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(mocks.withAuth).toHaveBeenCalled(); + expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + }); + + test('skips the unsafe aggregate query when there are no approved servers', async () => { + const prisma = createPrismaMock(); + prisma.mcpServer.findMany.mockResolvedValue([]); + mocks.authContext = { + org: { id: 1 }, + role: OrgRole.OWNER, + prisma, + }; + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + expect(body).toEqual({ + servers: [], + totalSavedConnectionCount: 0, + allowedMode: 'approved_only', + }); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts new file mode 100644 index 000000000..5ed72eefc --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -0,0 +1,74 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { serviceErrorResponse, type ServiceError } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { hasEntitlement } from '@/lib/entitlements'; +import { withAuth } from '@/middleware/withAuth'; +import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; +import { __unsafePrisma } from '@/prisma'; +import { oauthNotSupported } from '@/ee/features/mcp/errors'; +import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; +import type { GetMcpConfigurationResponse } from '@/ee/features/mcp/types'; +import { OrgRole } from '@sourcebot/db'; +import type { NextRequest } from 'next/server'; + +export const GET = apiHandler(async (_request: NextRequest) => { + const result = await withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async (): Promise => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); + } + + const orgServers = await prisma.mcpServer.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + sanitizedName: true, + serverUrl: true, + }, + }); + + const serverIds = orgServers.map((server) => server.id); + const connectionCounts = serverIds.length === 0 + ? [] + : await __unsafePrisma.userMcpServer.groupBy({ + by: ['serverId'], + where: { + serverId: { in: serverIds }, + tokens: { not: null }, + server: { orgId: org.id }, + user: { + orgs: { + some: { orgId: org.id }, + }, + }, + }, + _count: { _all: true }, + }); + const countByServerId = new Map( + connectionCounts.map((row) => [row.serverId, row._count._all]), + ); + + const servers = orgServers.map((server) => { + const savedConnectionCount = countByServerId.get(server.id) ?? 0; + return { + ...server, + faviconUrl: getMcpFaviconUrl(server.serverUrl), + savedConnectionCount, + }; + }); + + return { + servers, + totalSavedConnectionCount: servers.reduce((total, server) => total + server.savedConnectionCount, 0), + allowedMode: 'approved_only', + }; + })); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index 98802eec6..aaaa005cd 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -6,20 +6,22 @@ import { hasEntitlement } from '@/lib/entitlements'; import { decryptOAuthToken } from '@sourcebot/shared'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import type { OAuthTokens } from '@ai-sdk/mcp'; +import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; +import type { NextRequest } from 'next/server'; export interface McpServerWithStatus { id: string; name: string; serverUrl: string; sanitizedName: string; - faviconUrl: string; + faviconUrl: string | undefined; isConnected: boolean; isAuthExpired: boolean; } export type GetMcpServersResponse = McpServerWithStatus[]; -export const GET = apiHandler(async () => { +export const GET = apiHandler(async (_request: NextRequest) => { if (!(await hasEntitlement('oauth'))) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, @@ -51,8 +53,7 @@ export const GET = apiHandler(async () => { return orgServers.map((server): McpServerWithStatus => { const userServer = userServerByServerId.get(server.id); - const origin = new URL(server.serverUrl).origin; - const faviconUrl = `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; + const faviconUrl = getMcpFaviconUrl(server.serverUrl); let isConnected = false; let isAuthExpired = false; diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts index 6bd7b02a5..e96a05997 100644 --- a/packages/web/src/ee/features/mcp/actions.test.ts +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -4,6 +4,7 @@ import { ErrorCode } from '@/lib/errorCodes'; const mocks = vi.hoisted(() => ({ authContext: undefined as unknown, + hasEntitlement: vi.fn(), unsafePrisma: { mcpServer: { deleteMany: vi.fn(), @@ -15,6 +16,9 @@ vi.mock('server-only', () => ({})); vi.mock('@/middleware/withAuth', () => ({ withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), })); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); vi.mock('@/prisma', () => ({ __unsafePrisma: mocks.unsafePrisma, })); @@ -47,6 +51,7 @@ function setAuthContext(role: OrgRole, prisma = createPrismaMock()) { beforeEach(() => { vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); }); describe('createMcpServer', () => { @@ -82,6 +87,19 @@ describe('createMcpServer', () => { }); expect(prisma.mcpServer.create).not.toHaveBeenCalled(); }); + + test('owners cannot add org MCP servers when OAuth is unsupported', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + mocks.hasEntitlement.mockResolvedValue(false); + + const result = await createMcpServer('Linear', 'https://mcp.linear.app/mcp'); + + expect(result).toMatchObject({ + statusCode: 403, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + }); }); describe('deleteMcpServer', () => { @@ -108,4 +126,17 @@ describe('deleteMcpServer', () => { }); expect(mocks.unsafePrisma.mcpServer.deleteMany).not.toHaveBeenCalled(); }); + + test('owners cannot delete org MCP servers when OAuth is unsupported', async () => { + setAuthContext(OrgRole.OWNER); + mocks.hasEntitlement.mockResolvedValue(false); + + const result = await deleteMcpServer('server-1'); + + expect(result).toMatchObject({ + statusCode: 403, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(mocks.unsafePrisma.mcpServer.deleteMany).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index 8dc2dbc21..ea4f1c47f 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -10,10 +10,16 @@ import { OrgRole } from '@sourcebot/db'; import { StatusCodes } from 'http-status-codes'; import { z } from 'zod'; import { sanitizeMcpServerName } from './utils'; +import { hasEntitlement } from '@/lib/entitlements'; +import { oauthNotSupported } from './errors'; export const createMcpServer = async (name: string, serverUrl: string) => sew(() => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); + } + const displayName = name.trim(); const normalizedServerUrl = serverUrl.trim(); const urlResult = z.string().url().safeParse(normalizedServerUrl); @@ -89,6 +95,10 @@ export const createMcpServer = async (name: string, serverUrl: string) => sew(() export const deleteMcpServer = async (serverId: string) => sew(() => withAuth(async ({ org, role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); + } + const result = await __unsafePrisma.mcpServer.deleteMany({ where: { id: serverId, diff --git a/packages/web/src/ee/features/mcp/errors.ts b/packages/web/src/ee/features/mcp/errors.ts new file mode 100644 index 000000000..12a0c79a9 --- /dev/null +++ b/packages/web/src/ee/features/mcp/errors.ts @@ -0,0 +1,10 @@ +import { ErrorCode } from '@/lib/errorCodes'; +import { ServiceError } from '@/lib/serviceError'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { StatusCodes } from 'http-status-codes'; + +export const oauthNotSupported = (): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, +}); diff --git a/packages/web/src/ee/features/mcp/queryKeys.test.ts b/packages/web/src/ee/features/mcp/queryKeys.test.ts new file mode 100644 index 000000000..f897f486a --- /dev/null +++ b/packages/web/src/ee/features/mcp/queryKeys.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { QueryClient } from '@tanstack/react-query'; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from './queryKeys'; + +describe('invalidateMcpConfigurationQueries', () => { + test('invalidates both admin configuration and account MCP server status', async () => { + const queryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryClient; + + await invalidateMcpConfigurationQueries(queryClient); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpQueryKeys.configuration }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpQueryKeys.serversWithStatus }); + }); +}); diff --git a/packages/web/src/ee/features/mcp/queryKeys.ts b/packages/web/src/ee/features/mcp/queryKeys.ts new file mode 100644 index 000000000..469c9fc04 --- /dev/null +++ b/packages/web/src/ee/features/mcp/queryKeys.ts @@ -0,0 +1,13 @@ +import type { QueryClient } from '@tanstack/react-query'; + +export const mcpQueryKeys = { + serversWithStatus: ['mcpServersWithStatus'] as const, + configuration: ['mcpConfiguration'] as const, +}; + +export async function invalidateMcpConfigurationQueries(queryClient: QueryClient) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: mcpQueryKeys.configuration }), + queryClient.invalidateQueries({ queryKey: mcpQueryKeys.serversWithStatus }), + ]); +} diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/mcp/types.ts new file mode 100644 index 000000000..7d152a550 --- /dev/null +++ b/packages/web/src/ee/features/mcp/types.ts @@ -0,0 +1,16 @@ +export interface McpConfigurationServer { + id: string; + name: string; + serverUrl: string; + sanitizedName: string; + faviconUrl: string | undefined; + savedConnectionCount: number; +} + +export type McpConfigurationAllowedMode = 'approved_only'; + +export interface GetMcpConfigurationResponse { + servers: McpConfigurationServer[]; + totalSavedConnectionCount: number; + allowedMode: McpConfigurationAllowedMode; +} diff --git a/packages/web/src/ee/features/mcp/utils.test.ts b/packages/web/src/ee/features/mcp/utils.test.ts index c4a63ffc3..b82f3130b 100644 --- a/packages/web/src/ee/features/mcp/utils.test.ts +++ b/packages/web/src/ee/features/mcp/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from 'vitest'; -import { sanitizeMcpServerName } from './utils'; +import { getMcpFaviconUrl, sanitizeMcpServerName } from './utils'; describe('sanitizeMcpServerName', () => { test('lowercases ASCII letters', () => { @@ -34,3 +34,13 @@ describe('sanitizeMcpServerName', () => { expect(sanitizeMcpServerName('linear')).toBe('linear'); }); }); + +describe('getMcpFaviconUrl', () => { + test('returns a Google favicon URL for a valid server URL', () => { + expect(getMcpFaviconUrl('https://mcp.linear.app/mcp')).toBe('https://www.google.com/s2/favicons?domain=https://mcp.linear.app&sz=32'); + }); + + test('returns undefined for a malformed server URL', () => { + expect(getMcpFaviconUrl('not a url')).toBeUndefined(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts index 3a0176dba..4997c2745 100644 --- a/packages/web/src/ee/features/mcp/utils.ts +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -8,4 +8,13 @@ */ export function sanitizeMcpServerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '_'); -} \ No newline at end of file +} + +export function getMcpFaviconUrl(serverUrl: string): string | undefined { + try { + const origin = new URL(serverUrl).origin; + return `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; + } catch { + return undefined; + } +} diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx index 882e75ce2..4b304f41a 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Switch } from "@/components/ui/switch"; import { getMcpServersWithStatus } from "@/app/api/(client)/client"; +import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -34,7 +35,7 @@ export const ChatBoxPlusButton = ({ const router = useRouter(); const { data: servers, isError, refetch } = useQuery({ - queryKey: ['mcpServersWithStatus'], + queryKey: mcpQueryKeys.serversWithStatus, queryFn: async () => { const result = await getMcpServersWithStatus(); if (isServiceError(result)) { From ffe0d875cc68b27cbd8545126231304657289c4b Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 16:42:25 -0700 Subject: [PATCH 07/40] fix(web): allow MCP cleanup without OAuth entitlement --- .../web/src/app/(app)/settings/layout.tsx | 12 +- .../mcpConfiguration/mcpConfigurationPage.tsx | 114 +++++++++++------- .../settings/mcpConfiguration/page.test.tsx | 25 +++- .../(app)/settings/mcpConfiguration/page.tsx | 10 +- .../ee/askmcp/configuration/route.test.ts | 23 +++- .../(server)/ee/askmcp/configuration/route.ts | 10 +- .../web/src/ee/features/mcp/actions.test.ts | 16 ++- packages/web/src/ee/features/mcp/actions.ts | 4 - packages/web/src/ee/features/mcp/types.ts | 1 + 9 files changed, 145 insertions(+), 70 deletions(-) diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index ad33603c3..6a65ab415 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -44,7 +44,7 @@ export default async function SettingsLayout( } export const getSidebarNavGroups = async () => - withAuth(async ({ role }) => { + withAuth(async ({ org, role, prisma }) => { let numJoinRequests: number | undefined; if (role === OrgRole.OWNER) { const requests = await getOrgAccountRequests(); @@ -58,6 +58,12 @@ export const getSidebarNavGroups = async () => if (isServiceError(connectionStats)) { throw new ServiceErrorException(connectionStats); } + const hasOAuthEntitlement = await hasEntitlement("oauth"); + const hasApprovedMcpServers = role === OrgRole.OWNER && !hasOAuthEntitlement + ? await prisma.mcpServer.count({ + where: { orgId: org.id }, + }) > 0 + : false; const groups: NavGroup[] = [ { @@ -82,7 +88,7 @@ export const getSidebarNavGroups = async () => icon: "link" as const, } ] : []), - ...(await hasEntitlement("oauth") ? [ + ...(hasOAuthEntitlement ? [ { title: "MCP Servers", href: `/settings/mcpServers`, @@ -121,7 +127,7 @@ export const getSidebarNavGroups = async () => icon: "chart-area" as const, requiredEntitlement: 'analytics' }, - ...(await hasEntitlement("oauth") ? [ + ...(hasOAuthEntitlement || hasApprovedMcpServers ? [ { title: "MCP Configuration", href: `/settings/mcpConfiguration`, diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx index bf5eb3dff..9a49467ca 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -20,7 +20,7 @@ import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Loader2, MinusIcon, PlusIcon, ServerIcon } from "lucide-react"; +import { AlertTriangleIcon, Loader2, MinusIcon, PlusIcon, ServerIcon } from "lucide-react"; function pluralize(count: number, singular: string, plural = `${singular}s`) { return count === 1 ? singular : plural; @@ -48,6 +48,8 @@ export function McpConfigurationPage() { const servers = data?.servers ?? []; const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; + const canCreateMcpServers = data?.isOAuthAvailable === true; + const isOAuthUnavailable = data?.isOAuthAvailable === false; const handleCloseCreateDialog = () => { setIsCreateDialogOpen(false); @@ -108,6 +110,20 @@ export function McpConfigurationPage() {

+ {!isLoading && isOAuthUnavailable && ( + + + +
+

OAuth MCP is unavailable

+

+ You can remove existing approved servers and stored credentials, but cannot add new MCP servers. +

+
+
+
+ )} +
@@ -129,7 +145,9 @@ export function McpConfigurationPage() {

Allowed MCP servers

- Sourcebot Ask can use only workspace-approved MCP servers. + {isOAuthUnavailable + ? "Existing workspace-approved MCP servers are available for cleanup." + : "Sourcebot Ask can use only workspace-approved MCP servers."}

Only approved servers

@@ -141,47 +159,57 @@ export function McpConfigurationPage() {
{isLoading ? "Allowed servers" : `${servers.length} allowed ${pluralize(servers.length, "server")}`} - Approve server URLs that workspace members can connect to. + + {isOAuthUnavailable + ? "Remove existing server approvals and their stored credentials." + : "Approve server URLs that workspace members can connect to."} +
- - - - - - - Add MCP Server - -
-
- - setNewServerName(event.target.value)} - placeholder="e.g. Linear" - /> -
-
- - setNewServerUrl(event.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> -
-
- - - - -
-
+ + + + Add MCP Server + +
+
+ + setNewServerName(event.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(event.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+ + ) : ( + + )}
{isLoading ? ( @@ -204,7 +232,9 @@ export function McpConfigurationPage() {

No MCP servers configured yet

- Add a workspace-approved MCP server so members can connect it to Ask Sourcebot. + {isOAuthUnavailable + ? "OAuth MCP is unavailable on this Sourcebot instance." + : "Add a workspace-approved MCP server so members can connect it to Ask Sourcebot."}

) : ( diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx index 7fe9219bd..f349a072a 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx @@ -3,6 +3,14 @@ import { cleanup, render, screen } from '@testing-library/react'; import type React from 'react'; const mocks = vi.hoisted(() => ({ + authContext: { + org: { id: 1 }, + prisma: { + mcpServer: { + count: vi.fn(), + }, + }, + }, hasEntitlement: vi.fn(), })); @@ -10,7 +18,7 @@ vi.mock('@/lib/entitlements', () => ({ hasEntitlement: mocks.hasEntitlement, })); vi.mock('@/middleware/authenticatedPage', () => ({ - authenticatedPage: vi.fn((page: () => Promise) => page), + authenticatedPage: vi.fn((page: (auth: typeof mocks.authContext) => Promise) => () => page(mocks.authContext)), })); vi.mock('./mcpConfigurationPage', () => ({ McpConfigurationPage: () =>
MCP configuration client
, @@ -21,6 +29,7 @@ const { default: Page } = await import('./page'); beforeEach(() => { vi.clearAllMocks(); mocks.hasEntitlement.mockResolvedValue(true); + mocks.authContext.prisma.mcpServer.count.mockResolvedValue(0); }); afterEach(() => { @@ -34,7 +43,19 @@ describe('MCP configuration settings page', () => { expect(screen.getByText('MCP configuration client')).toBeTruthy(); }); - test('renders an unavailable message when OAuth is not available', async () => { + test('renders the client configuration page when OAuth is unavailable but servers exist for cleanup', async () => { + mocks.hasEntitlement.mockResolvedValue(false); + mocks.authContext.prisma.mcpServer.count.mockResolvedValue(1); + + render(await Page({})); + + expect(screen.getByText('MCP configuration client')).toBeTruthy(); + expect(mocks.authContext.prisma.mcpServer.count).toHaveBeenCalledWith({ + where: { orgId: 1 }, + }); + }); + + test('renders an unavailable message when OAuth is not available and no cleanup is needed', async () => { mocks.hasEntitlement.mockResolvedValue(false); render(await Page({})); diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx index 25e0a23fc..c6c1015f5 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx @@ -4,9 +4,15 @@ import { OrgRole } from "@sourcebot/db"; import { McpConfigurationPage } from "./mcpConfigurationPage"; import { McpConfigurationUnavailableMessage } from "./mcpConfigurationUnavailableMessage"; -export default authenticatedPage(async () => { +export default authenticatedPage(async ({ org, prisma }) => { if (!(await hasEntitlement("oauth"))) { - return ; + const serverCount = await prisma.mcpServer.count({ + where: { orgId: org.id }, + }); + + if (serverCount === 0) { + return ; + } } return ; diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts index 52f088b58..a89f382ef 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts @@ -105,6 +105,7 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(body).toMatchObject({ totalSavedConnectionCount: 2, allowedMode: 'approved_only', + isOAuthAvailable: true, servers: [ { id: 'server-1', @@ -158,7 +159,7 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); }); - test('rejects entitled owners when OAuth is unsupported before data work', async () => { + test('allows entitled owners to list cleanup data when OAuth is unsupported', async () => { const prisma = createPrismaMock(); mocks.authContext = { org: { id: 1 }, @@ -170,13 +171,24 @@ describe('GET /api/ee/askmcp/configuration', () => { const response = await GET(createRequest()); const body = await response.json(); - expect(response.status).toBe(403); + expect(response.status).toBe(200); expect(body).toMatchObject({ - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + isOAuthAvailable: false, + totalSavedConnectionCount: 2, + servers: [ + { + id: 'server-1', + savedConnectionCount: 2, + }, + { + id: 'server-2', + savedConnectionCount: 0, + }, + ], }); expect(mocks.withAuth).toHaveBeenCalled(); - expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + expect(prisma.mcpServer.findMany).toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.groupBy).toHaveBeenCalled(); }); test('skips the unsafe aggregate query when there are no approved servers', async () => { @@ -196,6 +208,7 @@ describe('GET /api/ee/askmcp/configuration', () => { servers: [], totalSavedConnectionCount: 0, allowedMode: 'approved_only', + isOAuthAvailable: true, }); }); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts index 5ed72eefc..9a3901108 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -1,11 +1,10 @@ import { apiHandler } from '@/lib/apiHandler'; -import { serviceErrorResponse, type ServiceError } from '@/lib/serviceError'; +import { serviceErrorResponse } from '@/lib/serviceError'; import { isServiceError } from '@/lib/utils'; import { hasEntitlement } from '@/lib/entitlements'; import { withAuth } from '@/middleware/withAuth'; import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; import { __unsafePrisma } from '@/prisma'; -import { oauthNotSupported } from '@/ee/features/mcp/errors'; import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; import type { GetMcpConfigurationResponse } from '@/ee/features/mcp/types'; import { OrgRole } from '@sourcebot/db'; @@ -13,10 +12,8 @@ import type { NextRequest } from 'next/server'; export const GET = apiHandler(async (_request: NextRequest) => { const result = await withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async (): Promise => { - if (!(await hasEntitlement('oauth'))) { - return oauthNotSupported(); - } + withMinimumOrgRole(role, OrgRole.OWNER, async (): Promise => { + const isOAuthAvailable = await hasEntitlement('oauth'); const orgServers = await prisma.mcpServer.findMany({ where: { orgId: org.id }, @@ -63,6 +60,7 @@ export const GET = apiHandler(async (_request: NextRequest) => { servers, totalSavedConnectionCount: servers.reduce((total, server) => total + server.savedConnectionCount, 0), allowedMode: 'approved_only', + isOAuthAvailable, }; })); diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts index e96a05997..9ac884caa 100644 --- a/packages/web/src/ee/features/mcp/actions.test.ts +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -114,6 +114,7 @@ describe('deleteMcpServer', () => { orgId: 1, }, }); + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); }); test('members cannot delete org MCP servers', async () => { @@ -127,16 +128,19 @@ describe('deleteMcpServer', () => { expect(mocks.unsafePrisma.mcpServer.deleteMany).not.toHaveBeenCalled(); }); - test('owners cannot delete org MCP servers when OAuth is unsupported', async () => { + test('owners can delete org MCP servers when OAuth is unsupported', async () => { setAuthContext(OrgRole.OWNER); mocks.hasEntitlement.mockResolvedValue(false); + mocks.unsafePrisma.mcpServer.deleteMany.mockResolvedValue({ count: 1 }); - const result = await deleteMcpServer('server-1'); + await expect(deleteMcpServer('server-1')).resolves.toEqual({ success: true }); - expect(result).toMatchObject({ - statusCode: 403, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServer.deleteMany).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + }, }); - expect(mocks.unsafePrisma.mcpServer.deleteMany).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index ea4f1c47f..04fa07beb 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -95,10 +95,6 @@ export const createMcpServer = async (name: string, serverUrl: string) => sew(() export const deleteMcpServer = async (serverId: string) => sew(() => withAuth(async ({ org, role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { - if (!(await hasEntitlement('oauth'))) { - return oauthNotSupported(); - } - const result = await __unsafePrisma.mcpServer.deleteMany({ where: { id: serverId, diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/mcp/types.ts index 7d152a550..6ddff31e4 100644 --- a/packages/web/src/ee/features/mcp/types.ts +++ b/packages/web/src/ee/features/mcp/types.ts @@ -13,4 +13,5 @@ export interface GetMcpConfigurationResponse { servers: McpConfigurationServer[]; totalSavedConnectionCount: number; allowedMode: McpConfigurationAllowedMode; + isOAuthAvailable: boolean; } From 1e6860b5f0bdc034b30e52f7177b2481c4f2c2ef Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 18:25:53 -0700 Subject: [PATCH 08/40] feat(web): improve MCP server add flow --- .../mcpConfiguration/mcpConfigurationPage.tsx | 213 +++++++++++++---- .../prefabMcpServerPopover.tsx | 133 +++++++++++ packages/web/src/ee/features/mcp/actions.ts | 50 ++++ .../src/ee/features/mcp/dcrDiscovery.test.ts | 217 ++++++++++++++++++ .../web/src/ee/features/mcp/dcrDiscovery.ts | 206 +++++++++++++++++ .../ee/features/mcp/prefabMcpServers.test.ts | 34 +++ .../src/ee/features/mcp/prefabMcpServers.ts | 37 +++ 7 files changed, 846 insertions(+), 44 deletions(-) create mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx create mode 100644 packages/web/src/ee/features/mcp/dcrDiscovery.test.ts create mode 100644 packages/web/src/ee/features/mcp/dcrDiscovery.ts create mode 100644 packages/web/src/ee/features/mcp/prefabMcpServers.test.ts create mode 100644 packages/web/src/ee/features/mcp/prefabMcpServers.ts diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx index 9a49467ca..6c79cb31b 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -10,17 +10,19 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { - Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; -import { createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { checkMcpServerDynamicClientRegistration, createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon, Loader2, MinusIcon, PlusIcon, ServerIcon } from "lucide-react"; +import { PrefabMcpServerPopover } from "./prefabMcpServerPopover"; +import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; function pluralize(count: number, singular: string, plural = `${singular}s`) { return count === 1 ? singular : plural; @@ -32,6 +34,10 @@ export function McpConfigurationPage() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [newServerName, setNewServerName] = useState(""); const [newServerUrl, setNewServerUrl] = useState(""); + const [isClientCredentialsDialogOpen, setIsClientCredentialsDialogOpen] = useState(false); + const [pendingClientCredentialsServer, setPendingClientCredentialsServer] = useState<{ name: string; serverUrl: string } | null>(null); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); const [isCreating, setIsCreating] = useState(false); const [deletingServerId, setDeletingServerId] = useState(null); @@ -51,28 +57,78 @@ export function McpConfigurationPage() { const canCreateMcpServers = data?.isOAuthAvailable === true; const isOAuthUnavailable = data?.isOAuthAvailable === false; + const handleCreateDialogOpenChange = (open: boolean) => { + setIsCreateDialogOpen(open); + + if (!open) { + setNewServerName(""); + setNewServerUrl(""); + } + }; + const handleCloseCreateDialog = () => { - setIsCreateDialogOpen(false); + handleCreateDialogOpenChange(false); + }; + + const handleCloseClientCredentialsDialog = () => { + setIsClientCredentialsDialogOpen(false); + setPendingClientCredentialsServer(null); + setClientId(""); + setClientSecret(""); + }; + + const handleOpenCustomUrlDialog = () => { setNewServerName(""); setNewServerUrl(""); + setIsCreateDialogOpen(true); }; - const handleCreate = async () => { - if (!newServerName.trim() || !newServerUrl.trim()) { + const handleCreateStaticOAuthServer = async () => { + toast({ + title: "Static OAuth credentials required", + description: "Saving MCP OAuth client credentials will be added in a follow-up change.", + }); + }; + + const handleCreateServer = async ( + name: string, + serverUrl: string, + onSuccess?: () => void, + options: { checkDynamicClientRegistration?: boolean } = {}, + ) => { + const displayName = name.trim(); + const normalizedServerUrl = serverUrl.trim(); + + if (!displayName || !normalizedServerUrl) { toast({ title: "Error", description: "Name and server URL are required", variant: "destructive" }); return; } setIsCreating(true); try { - const result = await createMcpServer(newServerName.trim(), newServerUrl.trim()); + if (options.checkDynamicClientRegistration) { + const dcrSupport = await checkMcpServerDynamicClientRegistration(normalizedServerUrl); + if (isServiceError(dcrSupport)) { + toast({ title: "Error", description: `Failed to check MCP server: ${dcrSupport.message}`, variant: "destructive" }); + return; + } + + if (dcrSupport.isKnown && !dcrSupport.supportsDcr) { + setPendingClientCredentialsServer({ name: displayName, serverUrl: normalizedServerUrl }); + setIsCreateDialogOpen(false); + setIsClientCredentialsDialogOpen(true); + return; + } + } + + const result = await createMcpServer(displayName, normalizedServerUrl); if (isServiceError(result)) { toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); return; } await invalidateMcpConfigurationQueries(queryClient); - handleCloseCreateDialog(); + onSuccess?.(); } catch (error) { toast({ title: "Error", description: `Failed to add MCP server: ${error}`, variant: "destructive" }); } finally { @@ -80,6 +136,16 @@ export function McpConfigurationPage() { } }; + const handleCreate = async () => { + await handleCreateServer(newServerName, newServerUrl, handleCloseCreateDialog, { + checkDynamicClientRegistration: true, + }); + }; + + const handleCreatePrefabServer = async (server: PrefabMcpServer) => { + await handleCreateServer(server.name, server.serverUrl); + }; + const handleDelete = async (serverId: string) => { setDeletingServerId(serverId); try { @@ -166,45 +232,104 @@ export function McpConfigurationPage() {
{canCreateMcpServers ? ( - - - - - - - Add MCP Server - -
-
- - setNewServerName(event.target.value)} - placeholder="e.g. Linear" - /> + <> + server.serverUrl)} + disabled={isCreating} + onSelectCustomUrl={handleOpenCustomUrlDialog} + onSelectPrefabServer={handleCreatePrefabServer} + /> + + + + Add MCP Server + + Add a workspace-approved MCP server that members can connect to from Ask Sourcebot. + + +
+
+ + setNewServerName(event.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(event.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
-
- - setNewServerUrl(event.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> + + + + + +
+ { + if (!open) { + handleCloseClientCredentialsDialog(); + return; + } + + setIsClientCredentialsDialogOpen(true); + }}> + + + OAuth Client Credentials Required + + This MCP server does not advertise dynamic client registration. Provide OAuth client credentials from a pre-registered app before members can connect to it. + + +
+ {pendingClientCredentialsServer && ( +
+

{pendingClientCredentialsServer.name}

+

{pendingClientCredentialsServer.serverUrl}

+
+ )} +
+ + setClientId(event.target.value)} + placeholder="OAuth client ID" + /> +
+
+ + setClientSecret(event.target.value)} + placeholder="OAuth client secret" + /> +
-
- - - - - -
+ + + + + + + ) : ( + + + + + + + {filteredPrefabServers.map((server) => ( + handleSelectPrefabServer(server)} + className="cursor-pointer" + > +
+ +
+
+

{server.name}

+

{getDisplayServerUrl(server.serverUrl)}

+
+
+ ))} + {search.trim() && filteredPrefabServers.length === 0 && ( +
+ No servers found. +
+ )} +
+ + + + + Custom URL... + + +
+
+
+ + ); +} diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index 04fa07beb..dd8f985f0 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -12,6 +12,56 @@ import { z } from 'zod'; import { sanitizeMcpServerName } from './utils'; import { hasEntitlement } from '@/lib/entitlements'; import { oauthNotSupported } from './errors'; +import { checkMcpServerDcrSupport } from './dcrDiscovery'; +import { env } from '@sourcebot/shared'; + +const MCP_DCR_DISCOVERY_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 10000); + +function createTimeoutFetch(timeoutMs: number): typeof fetch { + return async (input, init) => { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal; + + return fetch(input, { + ...init, + signal, + }); + }; +} + +export const checkMcpServerDynamicClientRegistration = async (serverUrl: string) => sew(() => + withAuth(async ({ role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); + } + + const normalizedServerUrl = serverUrl.trim(); + const urlResult = z.string().url().safeParse(normalizedServerUrl); + const protocol = urlResult.success ? new URL(normalizedServerUrl).protocol : undefined; + if (!urlResult.success || protocol !== 'https:') { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid server URL. Must be a valid HTTPS URL.', + } satisfies ServiceError; + } + + try { + return await checkMcpServerDcrSupport( + normalizedServerUrl, + createTimeoutFetch(MCP_DCR_DISCOVERY_TIMEOUT_MS), + ); + } catch { + return { + statusCode: StatusCodes.BAD_GATEWAY, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: 'Could not check whether this MCP server supports dynamic client registration.', + } satisfies ServiceError; + } + }))); export const createMcpServer = async (name: string, serverUrl: string) => sew(() => withAuth(async ({ org, role, prisma }) => diff --git a/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts b/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts new file mode 100644 index 000000000..8cd4facbc --- /dev/null +++ b/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test, vi } from 'vitest'; +import { checkMcpServerDcrSupport } from './dcrDiscovery'; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function notFoundResponse() { + return new Response('Not found', { status: 404 }); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +describe('checkMcpServerDcrSupport', () => { + test('returns supported when authorization server metadata advertises a registration endpoint', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url === 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp') { + return jsonResponse({ authorization_servers: ['https://auth.example.com'] }); + } + if (url === 'https://auth.example.com/.well-known/oauth-authorization-server') { + return jsonResponse({ registration_endpoint: 'https://auth.example.com/register' }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + await expect(checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock)).resolves.toEqual({ + supportsDcr: true, + isKnown: true, + authorizationServerUrl: 'https://auth.example.com', + registrationEndpoint: 'https://auth.example.com/register', + }); + }); + + test('returns unsupported when authorization server metadata does not advertise a registration endpoint', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url === 'https://mcp.slack.com/.well-known/oauth-protected-resource') { + return jsonResponse({ authorization_servers: ['https://mcp.slack.com'] }); + } + if (url === 'https://mcp.slack.com/.well-known/oauth-authorization-server') { + return jsonResponse({ + authorization_endpoint: 'https://slack.com/oauth/v2_user/authorize', + token_endpoint: 'https://slack.com/api/oauth.v2.user.access', + }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + await expect(checkMcpServerDcrSupport('https://mcp.slack.com/mcp', fetchMock)).resolves.toEqual({ + supportsDcr: false, + isKnown: true, + authorizationServerUrl: 'https://mcp.slack.com', + }); + }); + + test('falls back to the resource metadata URL from a bearer challenge', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url === 'https://auth.example.com/.well-known/oauth-authorization-server') { + return jsonResponse({ registration_endpoint: 'https://auth.example.com/register' }); + } + if (url.includes('/.well-known/')) { + return notFoundResponse(); + } + if (url === 'https://mcp.example.com/mcp') { + return new Response('', { + status: 401, + headers: { + 'www-authenticate': 'Bearer resource_metadata="https://metadata.example.com/oauth-protected-resource"', + }, + }); + } + if (url === 'https://metadata.example.com/oauth-protected-resource') { + return jsonResponse({ authorization_servers: ['https://auth.example.com'] }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + const result = await checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock); + + expect(result.supportsDcr).toBe(true); + expect(result.isKnown).toBe(true); + }); + + test('ignores non-bearer authenticate challenges', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url.includes('/.well-known/')) { + return notFoundResponse(); + } + if (url === 'https://mcp.example.com/mcp') { + return new Response('', { + status: 401, + headers: { + 'www-authenticate': 'Basic realm="mcp"', + }, + }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + await expect(checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock)).resolves.toEqual({ + supportsDcr: true, + isKnown: false, + authorizationServerUrl: 'https://mcp.example.com/mcp', + }); + }); + + test('ignores malformed bearer resource metadata URLs', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url.includes('/.well-known/')) { + return notFoundResponse(); + } + if (url === 'https://mcp.example.com/mcp') { + return new Response('', { + status: 401, + headers: { + 'www-authenticate': 'Bearer resource_metadata="not a url"', + }, + }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + await expect(checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock)).resolves.toEqual({ + supportsDcr: true, + isKnown: false, + authorizationServerUrl: 'https://mcp.example.com/mcp', + }); + }); + + test('ignores bearer resource metadata parameters without quotes', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url.includes('/.well-known/')) { + return notFoundResponse(); + } + if (url === 'https://mcp.example.com/mcp') { + return new Response('', { + status: 401, + headers: { + 'www-authenticate': 'Bearer resource_metadata=https://metadata.example.com/oauth-protected-resource', + }, + }); + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + await expect(checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock)).resolves.toEqual({ + supportsDcr: true, + isKnown: false, + authorizationServerUrl: 'https://mcp.example.com/mcp', + }); + }); + + test('starts authorization server metadata candidate requests concurrently while preserving priority', async () => { + const pathScopedOAuthMetadata = deferredResponse(); + const rootOAuthMetadata = deferredResponse(); + const pathScopedOidcMetadata = deferredResponse(); + const nestedOidcMetadata = deferredResponse(); + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url === 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp') { + return jsonResponse({ authorization_servers: ['https://auth.example.com/tenant'] }); + } + if (url === 'https://auth.example.com/.well-known/oauth-authorization-server/tenant') { + return pathScopedOAuthMetadata.promise; + } + if (url === 'https://auth.example.com/.well-known/oauth-authorization-server') { + return rootOAuthMetadata.promise; + } + if (url === 'https://auth.example.com/.well-known/openid-configuration/tenant') { + return pathScopedOidcMetadata.promise; + } + if (url === 'https://auth.example.com/tenant/.well-known/openid-configuration') { + return nestedOidcMetadata.promise; + } + return notFoundResponse(); + }) as unknown as typeof fetch; + + const resultPromise = checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock); + await vi.waitFor(() => { + const requestedUrls = fetchMock.mock.calls.map(([input]) => input.toString()); + + expect(requestedUrls).toContain('https://auth.example.com/.well-known/oauth-authorization-server/tenant'); + expect(requestedUrls).toContain('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(requestedUrls).toContain('https://auth.example.com/.well-known/openid-configuration/tenant'); + expect(requestedUrls).toContain('https://auth.example.com/tenant/.well-known/openid-configuration'); + }); + + rootOAuthMetadata.resolve(jsonResponse({ registration_endpoint: 'https://auth.example.com/register' })); + pathScopedOidcMetadata.resolve(notFoundResponse()); + nestedOidcMetadata.resolve(notFoundResponse()); + await Promise.resolve(); + + pathScopedOAuthMetadata.resolve(notFoundResponse()); + + await expect(resultPromise).resolves.toEqual({ + supportsDcr: true, + isKnown: true, + authorizationServerUrl: 'https://auth.example.com/tenant', + registrationEndpoint: 'https://auth.example.com/register', + }); + }); +}); diff --git a/packages/web/src/ee/features/mcp/dcrDiscovery.ts b/packages/web/src/ee/features/mcp/dcrDiscovery.ts new file mode 100644 index 000000000..286883d50 --- /dev/null +++ b/packages/web/src/ee/features/mcp/dcrDiscovery.ts @@ -0,0 +1,206 @@ +import { z } from 'zod'; + +const MCP_PROTOCOL_VERSION = '2025-11-25'; + +const protectedResourceMetadataSchema = z.object({ + authorization_servers: z.array(z.string().url()).optional(), +}).passthrough(); + +const authorizationServerMetadataSchema = z.object({ + registration_endpoint: z.string().url().optional(), +}).passthrough(); + +export interface McpServerDcrSupport { + supportsDcr: boolean; + isKnown: boolean; + authorizationServerUrl?: string; + registrationEndpoint?: string; +} + +function getMetadataHeaders() { + return { + Accept: 'application/json', + 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, + }; +} + +function buildProtectedResourceMetadataUrls(serverUrl: URL): URL[] { + const urls: URL[] = []; + const pathname = serverUrl.pathname.endsWith('/') + ? serverUrl.pathname.slice(0, -1) + : serverUrl.pathname; + + if (pathname && pathname !== '/') { + urls.push(new URL(`/.well-known/oauth-protected-resource${pathname}`, serverUrl.origin)); + } + + urls.push(new URL('/.well-known/oauth-protected-resource', serverUrl.origin)); + return urls; +} + +function buildAuthorizationServerMetadataUrls(authorizationServerUrl: URL): URL[] { + const hasPath = authorizationServerUrl.pathname !== '/'; + + if (!hasPath) { + return [ + new URL('/.well-known/oauth-authorization-server', authorizationServerUrl.origin), + new URL('/.well-known/openid-configuration', authorizationServerUrl.origin), + ]; + } + + const pathname = authorizationServerUrl.pathname.endsWith('/') + ? authorizationServerUrl.pathname.slice(0, -1) + : authorizationServerUrl.pathname; + + return [ + new URL(`/.well-known/oauth-authorization-server${pathname}`, authorizationServerUrl.origin), + new URL('/.well-known/oauth-authorization-server', authorizationServerUrl.origin), + new URL(`/.well-known/openid-configuration${pathname}`, authorizationServerUrl.origin), + new URL(`${pathname}/.well-known/openid-configuration`, authorizationServerUrl.origin), + ]; +} + +function normalizeUrlForOutput(url: URL): string { + return url.toString().replace(/\/$/, ''); +} + +function extractResourceMetadataUrl(response: Response): URL | undefined { + const header = response.headers.get('www-authenticate'); + if (!header) { + return undefined; + } + + if (!header.toLowerCase().startsWith('bearer ')) { + return undefined; + } + + const match = header.match(/resource_metadata="([^"]+)"/); + if (!match) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} + +async function fetchJson(url: URL, fetchFn: typeof fetch): Promise { + const response = await fetchFn(url, { headers: getMetadataHeaders() }); + + if (!response.ok) { + return undefined; + } + + return response.json(); +} + +async function fetchMetadataByPriority( + urls: URL[], + fetchFn: typeof fetch, + schema: z.ZodType, +): Promise { + const metadataPromises = urls.map(async (url) => { + try { + const json = await fetchJson(url, fetchFn); + const metadata = schema.safeParse(json); + return metadata.success ? metadata.data : undefined; + } catch { + return undefined; + } + }); + + for (const metadataPromise of metadataPromises) { + const metadata = await metadataPromise; + if (metadata) { + return metadata; + } + } + + return undefined; +} + +async function discoverProtectedResourceMetadata(serverUrl: URL, fetchFn: typeof fetch) { + const challengeMetadataPromise = (async () => { + try { + const response = await fetchFn(serverUrl, { headers: getMetadataHeaders() }); + const resourceMetadataUrl = extractResourceMetadataUrl(response); + if (!resourceMetadataUrl) { + return undefined; + } + + const json = await fetchJson(resourceMetadataUrl, fetchFn); + const metadata = protectedResourceMetadataSchema.safeParse(json); + return metadata.success ? metadata.data : undefined; + } catch { + return undefined; + } + })(); + + const wellKnownMetadata = await fetchMetadataByPriority( + buildProtectedResourceMetadataUrls(serverUrl), + fetchFn, + protectedResourceMetadataSchema, + ); + if (wellKnownMetadata) { + return wellKnownMetadata; + } + + return challengeMetadataPromise; +} + +async function discoverAuthorizationServerMetadata(authorizationServerUrl: URL, fetchFn: typeof fetch) { + return fetchMetadataByPriority( + buildAuthorizationServerMetadataUrls(authorizationServerUrl), + fetchFn, + authorizationServerMetadataSchema, + ); +} + +export async function checkMcpServerDcrSupport(serverUrl: string, fetchFn: typeof fetch = fetch): Promise { + const parsedServerUrl = new URL(serverUrl); + const protectedResourceMetadata = await discoverProtectedResourceMetadata(parsedServerUrl, fetchFn); + const authorizationServerUrls = protectedResourceMetadata?.authorization_servers?.length + ? protectedResourceMetadata.authorization_servers + : [parsedServerUrl.toString()]; + + let foundAuthorizationServerMetadata = false; + let firstAuthorizationServerUrl: URL | undefined; + for (const authorizationServer of authorizationServerUrls) { + const authorizationServerUrl = new URL(authorizationServer); + firstAuthorizationServerUrl ??= authorizationServerUrl; + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, fetchFn); + if (!authorizationServerMetadata) { + continue; + } + + foundAuthorizationServerMetadata = true; + if (authorizationServerMetadata.registration_endpoint) { + return { + supportsDcr: true, + isKnown: true, + authorizationServerUrl: normalizeUrlForOutput(authorizationServerUrl), + registrationEndpoint: authorizationServerMetadata.registration_endpoint, + }; + } + } + + if (foundAuthorizationServerMetadata) { + return { + supportsDcr: false, + isKnown: true, + authorizationServerUrl: firstAuthorizationServerUrl + ? normalizeUrlForOutput(firstAuthorizationServerUrl) + : undefined, + }; + } + + return { + supportsDcr: true, + isKnown: false, + authorizationServerUrl: firstAuthorizationServerUrl + ? normalizeUrlForOutput(firstAuthorizationServerUrl) + : undefined, + }; +} diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts new file mode 100644 index 000000000..48b8d2478 --- /dev/null +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { + getAvailablePrefabMcpServers, + normalizeMcpServerUrlForComparison, + PREFAB_MCP_SERVERS, +} from './prefabMcpServers'; + +describe('prefab MCP servers', () => { + test('ships Slack as the initial prefab server', () => { + expect(PREFAB_MCP_SERVERS).toEqual([ + { + id: 'slack', + name: 'Slack', + serverUrl: 'https://mcp.slack.com/mcp', + }, + ]); + }); + + test('keeps prefab servers sorted alphabetically by name', () => { + const sortedNames = PREFAB_MCP_SERVERS.map((server) => server.name).sort((a, b) => a.localeCompare(b)); + + expect(PREFAB_MCP_SERVERS.map((server) => server.name)).toEqual(sortedNames); + }); + + test('hides already configured prefab servers after URL normalization', () => { + const availableServers = getAvailablePrefabMcpServers(['https://mcp.slack.com/mcp/']); + + expect(availableServers).toEqual([]); + }); + + test('normalizes server URLs for duplicate comparisons', () => { + expect(normalizeMcpServerUrlForComparison(' HTTPS://MCP.SLACK.COM/mcp/#connect ')).toBe('https://mcp.slack.com/mcp'); + }); +}); diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.ts new file mode 100644 index 000000000..85dda6acd --- /dev/null +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.ts @@ -0,0 +1,37 @@ +export interface PrefabMcpServer { + id: string; + name: string; + serverUrl: string; +} + +const prefabMcpServers = [ + { + id: "slack", + name: "Slack", + serverUrl: "https://mcp.slack.com/mcp", + }, +] satisfies PrefabMcpServer[]; + +export const PREFAB_MCP_SERVERS = [...prefabMcpServers].sort((a, b) => a.name.localeCompare(b.name)); + +export function normalizeMcpServerUrlForComparison(serverUrl: string): string { + const trimmedServerUrl = serverUrl.trim(); + + try { + const url = new URL(trimmedServerUrl); + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return trimmedServerUrl.toLowerCase().replace(/\/$/, ""); + } +} + +export function getAvailablePrefabMcpServers(configuredServerUrls: string[]): PrefabMcpServer[] { + const configuredServerUrlSet = new Set( + configuredServerUrls.map((serverUrl) => normalizeMcpServerUrlForComparison(serverUrl)), + ); + + return PREFAB_MCP_SERVERS.filter((server) => ( + !configuredServerUrlSet.has(normalizeMcpServerUrlForComparison(server.serverUrl)) + )); +} From d193ce68e1fa5db21ce6a29c9087a7455efa4426 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 18:57:50 -0700 Subject: [PATCH 09/40] fix(web): check DCR for prefab MCP servers --- .../(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx index 6c79cb31b..36c226011 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -143,7 +143,9 @@ export function McpConfigurationPage() { }; const handleCreatePrefabServer = async (server: PrefabMcpServer) => { - await handleCreateServer(server.name, server.serverUrl); + await handleCreateServer(server.name, server.serverUrl, undefined, { + checkDynamicClientRegistration: true, + }); }; const handleDelete = async (serverId: string) => { From 820ecb361065c04d4a2ac2f395e9365b096ec3fb Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 19:06:41 -0700 Subject: [PATCH 10/40] feat(web): support static OAuth MCP credentials --- .../migration.sql | 5 + packages/db/prisma/schema.prisma | 12 +- .../mcpConfiguration/mcpConfigurationPage.tsx | 45 ++- .../(server)/ee/askmcp/callback/route.test.ts | 33 ++- .../api/(server)/ee/askmcp/callback/route.ts | 7 +- .../(server)/ee/askmcp/connect/route.test.ts | 61 +++- .../api/(server)/ee/askmcp/connect/route.ts | 28 +- .../web/src/ee/features/mcp/actions.test.ts | 254 +++++++++++++++- packages/web/src/ee/features/mcp/actions.ts | 272 ++++++++++++++---- .../src/ee/features/mcp/dcrDiscovery.test.ts | 4 +- .../ee/features/mcp/externalMcpError.test.ts | 66 +++++ .../src/ee/features/mcp/externalMcpError.ts | 174 +++++++++++ .../src/ee/features/mcp/mcpClientFactory.ts | 7 +- .../src/ee/features/mcp/mcpToolSets.test.ts | 32 ++- .../web/src/ee/features/mcp/mcpToolSets.ts | 13 +- .../mcp/prismaOAuthClientProvider.test.ts | 64 +++++ .../features/mcp/prismaOAuthClientProvider.ts | 26 +- 17 files changed, 992 insertions(+), 111 deletions(-) create mode 100644 packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql create mode 100644 packages/web/src/ee/features/mcp/externalMcpError.test.ts create mode 100644 packages/web/src/ee/features/mcp/externalMcpError.ts diff --git a/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql b/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql new file mode 100644 index 000000000..1f03e8968 --- /dev/null +++ b/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql @@ -0,0 +1,5 @@ +-- Track whether McpServer.clientInfo came from dynamic client registration or admin-provided static credentials. +CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC'); + +ALTER TABLE "McpServer" +ADD COLUMN "clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c0fe9eb10..b96626ecc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -328,6 +328,11 @@ enum OrgRole { MEMBER } +enum McpServerClientInfoSource { + DYNAMIC + STATIC +} + model UserToOrg { joinedAt DateTime @default(now()) @@ -655,10 +660,11 @@ model McpServer { sanitizedName String /// Stable tool-name prefix (e.g., "linear") serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp") - /// Dynamic client registration result (RFC 7591). + /// Dynamic client registration result (RFC 7591) or admin-provided static OAuth client credentials. /// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at } - /// Null until first user in the org triggers registration. - clientInfo String? + /// Null for DYNAMIC rows until first user in the org triggers registration. + clientInfo String? + clientInfoSource McpServerClientInfoSource @default(DYNAMIC) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx index 36c226011..83b5ccefd 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -15,7 +15,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; -import { checkMcpServerDynamicClientRegistration, createMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; @@ -84,10 +84,40 @@ export function McpConfigurationPage() { }; const handleCreateStaticOAuthServer = async () => { - toast({ - title: "Static OAuth credentials required", - description: "Saving MCP OAuth client credentials will be added in a follow-up change.", - }); + if (!pendingClientCredentialsServer) { + toast({ title: "Error", description: "Missing MCP server details", variant: "destructive" }); + return; + } + + if (process.env.NODE_ENV === "production" && window.location.protocol !== "https:") { + toast({ + title: "HTTPS required", + description: "Static OAuth client credentials can only be submitted over HTTPS in production.", + variant: "destructive", + }); + return; + } + + setIsCreating(true); + try { + const result = await createStaticOAuthMcpServer({ + name: pendingClientCredentialsServer.name, + serverUrl: pendingClientCredentialsServer.serverUrl, + clientId, + clientSecret, + }); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + handleCloseClientCredentialsDialog(); + } catch { + toast({ title: "Error", description: "Failed to add MCP server.", variant: "destructive" }); + } finally { + setIsCreating(false); + } }; const handleCreateServer = async ( @@ -305,6 +335,7 @@ export function McpConfigurationPage() { setClientId(event.target.value)} placeholder="OAuth client ID" /> @@ -315,6 +346,7 @@ export function McpConfigurationPage() { id="mcp-configuration-client-secret" type="password" value={clientSecret} + autoComplete="new-password" onChange={(event) => setClientSecret(event.target.value)} placeholder="OAuth client secret" /> @@ -324,8 +356,9 @@ export function McpConfigurationPage() { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index d5f53f136..5beceaf58 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -4,6 +4,12 @@ import { NextRequest } from 'next/server'; const mocks = vi.hoisted(() => ({ auth: vi.fn(), hasEntitlement: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, mcpAuth: vi.fn(), unsafePrisma: { mcpServer: { @@ -38,12 +44,7 @@ vi.mock('@sourcebot/shared', () => ({ env: { AUTH_URL: 'https://sourcebot.example.com', }, - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), + createLogger: () => mocks.logger, encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), })); @@ -80,7 +81,14 @@ describe('GET /api/ee/askmcp/callback', () => { mocks.mcpAuth.mockImplementation(async (provider) => { expect('saveClientInformation' in provider).toBe(false); await provider.invalidateCredentials('all'); - throw new Error('invalid_client'); + const error = new Error('invalid_client client_secret=client-secret refresh_token=refresh-token'); + Object.assign(error, { + response: { + status: 401, + body: 'client_secret=client-secret refresh_token=refresh-token', + }, + }); + throw error; }); const response = await GET(createRequest()); @@ -115,5 +123,16 @@ describe('GET /api/ee/askmcp/callback', () => { state: null, }, }); + expect(mocks.logger.warn).toHaveBeenCalledWith('Failed to authorize MCP server.', { + serverId: 'server-1', + orgId: 1, + error: { + errorClass: 'Error', + oauthError: 'invalid_client', + statusCode: 401, + }, + }); + expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('client-secret'); + expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('refresh-token'); }); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index e564d3839..23d694842 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -10,6 +10,7 @@ import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvi import { __unsafePrisma as prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextRequest, NextResponse } from 'next/server'; +import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; const logger = createLogger('mcp-oauth-callback'); const reconnectMessage = 'This MCP server authorization could not be completed. Please reconnect the server.'; @@ -117,7 +118,11 @@ export const GET = apiHandler(async (request: NextRequest) => { callbackState: state, }); } catch (error) { - logger.warn(`Failed to authorize MCP server ${userServer.server.name} for user ${session.user.id}:`, error); + logger.warn('Failed to authorize MCP server.', { + serverId: userServer.serverId, + orgId: userServer.server.orgId, + error: getExternalMcpErrorLogFields(error), + }); try { await provider.invalidateCredentials('verifier'); } catch (cleanupError) { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 6689585b4..0db07a56b 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { NextRequest } from 'next/server'; +import { McpServerClientInfoSource } from '@sourcebot/db'; const mocks = vi.hoisted(() => ({ authContext: undefined as unknown, hasEntitlement: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, mcpAuth: vi.fn(), unsafePrisma: { $transaction: vi.fn(), @@ -28,12 +35,7 @@ vi.mock('@sourcebot/shared', () => ({ AUTH_URL: 'https://sourcebot.example.com', SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 5000, }, - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), + createLogger: () => mocks.logger, encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), })); @@ -131,8 +133,53 @@ describe('POST /api/ee/askmcp/connect', () => { expect(tx.$queryRaw).toHaveBeenCalledOnce(); expect(tx.mcpServer.updateMany).toHaveBeenCalledWith({ where: { id: 'server-1', orgId: 1 }, - data: { clientInfo: 'encrypted:{"client_id":"client-1"}' }, + data: { + clientInfo: 'encrypted:{"client_id":"client-1"}', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }, }); expect(body).toEqual({ authorizationUrl: 'https://oauth.example.com/authorize' }); }); + + test('sanitizes external OAuth errors before logging', async () => { + const prisma = createPrismaMock(); + const tx = createTransactionMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + mocks.unsafePrisma.$transaction.mockImplementation(async (callback, _options) => callback(tx)); + mocks.mcpAuth.mockImplementation(async () => { + const error = new Error('invalid_client client_secret=client-secret refresh_token=refresh-token'); + Object.assign(error, { + response: { + status: 400, + body: 'client_secret=client-secret refresh_token=refresh-token', + }, + }); + throw error; + }); + + const response = await POST(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body).toMatchObject({ + message: 'Could not start MCP authorization.', + }); + expect(mocks.logger.warn).toHaveBeenCalledWith('Failed to start MCP authorization.', { + serverId: 'server-1', + orgId: 1, + error: { + errorClass: 'Error', + oauthError: 'invalid_client', + statusCode: 400, + }, + }); + expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('client-secret'); + expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('refresh-token'); + expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain('client-secret'); + expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain('refresh-token'); + }); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index a2ff9521b..87da5805a 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -10,10 +10,14 @@ import { z } from 'zod'; import { hasEntitlement } from '@/lib/entitlements'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import { ConnectMcpResponse } from "@/app/api/(server)/ee/askmcp/connect/types"; -import { env } from "@sourcebot/shared"; +import { createLogger, env } from "@sourcebot/shared"; import { __unsafePrisma } from '@/prisma'; +import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; +import { ErrorCode } from '@/lib/errorCodes'; +import { StatusCodes } from 'http-status-codes'; const bodySchema = z.object({ serverId: z.string() }); +const logger = createLogger('mcp-connect'); const MCP_AUTH_FETCH_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 30000); const MCP_AUTH_TRANSACTION_MAX_WAIT_MS = 10000; const MCP_AUTH_TRANSACTION_TIMEOUT_MS = MCP_AUTH_FETCH_TIMEOUT_MS + 5000; @@ -95,10 +99,24 @@ export const POST = apiHandler(async (request: NextRequest) => { allowClientRegistration: true, }); - const authResult = await mcpAuth(provider, { - serverUrl: new URL(mcpServer.serverUrl), - fetchFn: createTimeoutFetch(MCP_AUTH_FETCH_TIMEOUT_MS), - }); + let authResult: Awaited>; + try { + authResult = await mcpAuth(provider, { + serverUrl: new URL(mcpServer.serverUrl), + fetchFn: createTimeoutFetch(MCP_AUTH_FETCH_TIMEOUT_MS), + }); + } catch (error) { + logger.warn('Failed to start MCP authorization.', { + serverId: mcpServer.id, + orgId: org.id, + error: getExternalMcpErrorLogFields(error), + }); + throw new ServiceErrorException({ + statusCode: StatusCodes.BAD_GATEWAY, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: 'Could not start MCP authorization.', + }); + } return { authResult, diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts index 9ac884caa..a37a84e21 100644 --- a/packages/web/src/ee/features/mcp/actions.test.ts +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -1,10 +1,24 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { OrgRole } from '@sourcebot/db'; +import { McpServerClientInfoSource, OrgRole } from '@sourcebot/db'; import { ErrorCode } from '@/lib/errorCodes'; const mocks = vi.hoisted(() => ({ authContext: undefined as unknown, hasEntitlement: vi.fn(), + headers: vi.fn(async () => new Headers({ + host: 'sourcebot.example.com', + origin: 'https://sourcebot.example.com', + 'x-forwarded-proto': 'https', + })), + encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), + env: { + AUTH_URL: 'https://sourcebot.example.com', + NODE_ENV: 'production', + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 5000, + }, + logger: { + error: vi.fn(), + }, unsafePrisma: { mcpServer: { deleteMany: vi.fn(), @@ -13,6 +27,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('server-only', () => ({})); +vi.mock('next/headers', () => ({ + headers: mocks.headers, +})); vi.mock('@/middleware/withAuth', () => ({ withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), })); @@ -22,20 +39,25 @@ vi.mock('@/lib/entitlements', () => ({ vi.mock('@/prisma', () => ({ __unsafePrisma: mocks.unsafePrisma, })); +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => mocks.logger, + encryptOAuthToken: mocks.encryptOAuthToken, + env: mocks.env, +})); -const { createMcpServer, deleteMcpServer } = await import('./actions'); +const { createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } = await import('./actions'); function createPrismaMock() { return { mcpServer: { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({ + create: vi.fn().mockImplementation(async ({ data }) => ({ id: 'server-1', - name: 'Linear', - sanitizedName: 'linear', - serverUrl: 'https://mcp.linear.app/mcp', - }), + name: data.name, + sanitizedName: data.sanitizedName, + serverUrl: data.serverUrl, + })), }, }; } @@ -49,9 +71,33 @@ function setAuthContext(role: OrgRole, prisma = createPrismaMock()) { return prisma; } +function createStaticOAuthRequest(overrides: Partial<{ + name: string; + serverUrl: string; + clientId: string; + clientSecret: string; +}> = {}) { + return { + name: 'Slack', + serverUrl: 'https://mcp.slack.com/mcp', + clientId: 'client-id', + clientSecret: 'client-secret', + ...overrides, + }; +} + beforeEach(() => { vi.clearAllMocks(); mocks.hasEntitlement.mockResolvedValue(true); + mocks.headers.mockResolvedValue(new Headers({ + host: 'sourcebot.example.com', + origin: 'https://sourcebot.example.com', + 'x-forwarded-proto': 'https', + })); + mocks.encryptOAuthToken.mockImplementation((text: string | null | undefined) => text ? `encrypted:${text}` : undefined); + mocks.env.AUTH_URL = 'https://sourcebot.example.com'; + mocks.env.NODE_ENV = 'production'; + mocks.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = 5000; }); describe('createMcpServer', () => { @@ -72,6 +118,7 @@ describe('createMcpServer', () => { sanitizedName: 'linear', serverUrl: 'https://mcp.linear.app/mcp', clientInfo: null, + clientInfoSource: McpServerClientInfoSource.DYNAMIC, orgId: 1, }, }); @@ -102,6 +149,199 @@ describe('createMcpServer', () => { }); }); +describe('createStaticOAuthMcpServer', () => { + test('owners add a static OAuth MCP server with encrypted client information', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + + const result = await createStaticOAuthMcpServer({ + name: ' Slack ', + serverUrl: 'https://mcp.slack.com/mcp', + clientId: ' client-id ', + clientSecret: ' client-secret ', + }); + + expect(mocks.encryptOAuthToken).toHaveBeenCalledWith(JSON.stringify({ + client_id: 'client-id', + client_secret: 'client-secret', + })); + expect(prisma.mcpServer.create).toHaveBeenCalledWith({ + data: { + name: 'Slack', + sanitizedName: 'slack', + serverUrl: 'https://mcp.slack.com/mcp', + clientInfo: 'encrypted:{"client_id":"client-id","client_secret":"client-secret"}', + clientInfoSource: McpServerClientInfoSource.STATIC, + orgId: 1, + }, + }); + expect(JSON.stringify(result)).not.toContain('client-secret'); + expect(result).toEqual({ + id: 'server-1', + name: 'Slack', + sanitizedName: 'slack', + serverUrl: 'https://mcp.slack.com/mcp', + }); + }); + + test('members cannot add static OAuth MCP servers', async () => { + const prisma = setAuthContext(OrgRole.MEMBER); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); + + expect(result).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + }); + + test('rejects static OAuth credentials when production AUTH_URL is not HTTPS', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + mocks.env.AUTH_URL = 'http://sourcebot.example.com'; + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Static OAuth client credentials require HTTPS in production.', + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('rejects static OAuth credentials over insecure production requests', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + mocks.headers.mockResolvedValue(new Headers({ + host: 'sourcebot.example.com', + origin: 'http://sourcebot.example.com', + 'x-forwarded-proto': 'http', + })); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Static OAuth client credentials require HTTPS in production.', + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('does not echo client secrets in validation errors', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + + const result = await createStaticOAuthMcpServer({ + name: 'Slack', + serverUrl: 'not-a-url', + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + }); + expect(JSON.stringify(result)).not.toContain('client-secret'); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + }); + + test('rejects static OAuth servers with non-HTTPS server URLs', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest({ + serverUrl: 'http://mcp.slack.com/mcp', + })); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid server URL. Must be a valid HTTPS URL.', + }); + expect(prisma.mcpServer.findUnique).not.toHaveBeenCalled(); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(mocks.encryptOAuthToken).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('rejects static OAuth servers with fewer than 3 alphanumeric name characters', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest({ + name: '!!a!', + })); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Server name must contain at least 3 alphanumeric characters.', + }); + expect(prisma.mcpServer.findUnique).not.toHaveBeenCalled(); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(mocks.encryptOAuthToken).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('rejects static OAuth servers with a duplicate URL', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + prisma.mcpServer.findUnique.mockResolvedValue({ id: 'existing-server' }); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); + + expect(result).toMatchObject({ + statusCode: 409, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: 'An MCP server with URL "https://mcp.slack.com/mcp" already exists.', + }); + expect(prisma.mcpServer.findFirst).not.toHaveBeenCalled(); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(mocks.encryptOAuthToken).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('rejects static OAuth servers with a duplicate sanitized name', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + prisma.mcpServer.findFirst.mockResolvedValue({ id: 'existing-server' }); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest({ + name: 'Slack!!!', + })); + + expect(result).toMatchObject({ + statusCode: 409, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: 'An MCP server with a similar name already exists. Please choose a more distinct name.', + }); + expect(prisma.mcpServer.findUnique).toHaveBeenCalledWith({ + where: { + serverUrl_orgId: { + serverUrl: 'https://mcp.slack.com/mcp', + orgId: 1, + }, + }, + select: { id: true }, + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(mocks.encryptOAuthToken).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); + + test('rejects static OAuth servers when client credential encryption fails', async () => { + const prisma = setAuthContext(OrgRole.OWNER); + mocks.encryptOAuthToken.mockReturnValue(undefined); + + const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); + + expect(result).toMatchObject({ + statusCode: 500, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: 'Failed to store OAuth client credentials.', + }); + expect(prisma.mcpServer.create).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('client-secret'); + }); +}); + describe('deleteMcpServer', () => { test('owners delete through the narrowly scoped unsafe client', async () => { setAuthContext(OrgRole.OWNER); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index dd8f985f0..f000bb81b 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -2,20 +2,45 @@ import { sew } from '@/middleware/sew'; import { ErrorCode } from '@/lib/errorCodes'; -import { ServiceError } from '@/lib/serviceError'; +import { requestBodySchemaValidationError, ServiceError } from '@/lib/serviceError'; import { withAuth } from '@/middleware/withAuth'; import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; import { __unsafePrisma } from '@/prisma'; -import { OrgRole } from '@sourcebot/db'; +import { isServiceError } from '@/lib/utils'; +import { McpServerClientInfoSource, OrgRole, type PrismaClient } from '@sourcebot/db'; import { StatusCodes } from 'http-status-codes'; import { z } from 'zod'; import { sanitizeMcpServerName } from './utils'; import { hasEntitlement } from '@/lib/entitlements'; import { oauthNotSupported } from './errors'; import { checkMcpServerDcrSupport } from './dcrDiscovery'; -import { env } from '@sourcebot/shared'; +import { encryptOAuthToken, env } from '@sourcebot/shared'; +import { headers } from 'next/headers'; const MCP_DCR_DISCOVERY_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 10000); +const createStaticOAuthMcpServerSchema = z.object({ + name: z.string().trim().min(1), + serverUrl: z.string().trim().url(), + clientId: z.string().trim().min(1), + clientSecret: z.string().trim().min(1), +}); + +export type CreateStaticOAuthMcpServerRequest = z.infer; + +export interface CreateStaticOAuthMcpServerResponse { + id: string; + name: string; + sanitizedName: string; + serverUrl: string; +} + +type McpServerPrismaClient = Pick; + +interface PreparedMcpServerCreate { + displayName: string; + normalizedServerUrl: string; + sanitizedName: string; +} function createTimeoutFetch(timeoutMs: number): typeof fetch { return async (input, init) => { @@ -31,6 +56,118 @@ function createTimeoutFetch(timeoutMs: number): typeof fetch { }; } +function invalidRequest(message: string): ServiceError { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message, + }; +} + +function getFirstHeaderValue(value: string | null): string | undefined { + return value?.split(',')[0]?.trim().toLowerCase(); +} + +function getHeaderUrlProtocol(value: string | null, host: string | undefined): string | undefined { + if (!value || !host) { + return undefined; + } + + try { + const url = new URL(value); + return url.host === host ? url.protocol : undefined; + } catch { + return undefined; + } +} + +async function assertHttpsInProduction(): Promise { + if (env.NODE_ENV !== 'production') { + return undefined; + } + + const requestHeaders = await headers(); + const publicAuthUrlIsHttps = new URL(env.AUTH_URL).protocol === 'https:'; + const host = getFirstHeaderValue(requestHeaders.get('x-forwarded-host')) + ?? getFirstHeaderValue(requestHeaders.get('host')); + const originProtocol = getHeaderUrlProtocol(requestHeaders.get('origin'), host); + const refererProtocol = getHeaderUrlProtocol(requestHeaders.get('referer'), host); + const requestIsHttps = getFirstHeaderValue(requestHeaders.get('x-forwarded-proto')) === 'https' + || getFirstHeaderValue(requestHeaders.get('x-forwarded-ssl')) === 'on' + || originProtocol === 'https:' + || refererProtocol === 'https:'; + + if (publicAuthUrlIsHttps && requestIsHttps) { + return undefined; + } + + return invalidRequest('Static OAuth client credentials require HTTPS in production.'); +} + +async function prepareMcpServerCreate({ + prisma, + orgId, + name, + serverUrl, +}: { + prisma: McpServerPrismaClient; + orgId: number; + name: string; + serverUrl: string; +}): Promise { + const displayName = name.trim(); + const normalizedServerUrl = serverUrl.trim(); + const urlResult = z.string().url().safeParse(normalizedServerUrl); + const protocol = urlResult.success ? new URL(normalizedServerUrl).protocol : undefined; + if (!urlResult.success || protocol !== 'https:') { + return invalidRequest('Invalid server URL. Must be a valid HTTPS URL.'); + } + + const sanitizedName = sanitizeMcpServerName(displayName); + const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; + if (alphanumericCount < 3) { + return invalidRequest('Server name must contain at least 3 alphanumeric characters.'); + } + + const existingServer = await prisma.mcpServer.findUnique({ + where: { + serverUrl_orgId: { + serverUrl: normalizedServerUrl, + orgId, + }, + }, + select: { id: true }, + }); + if (existingServer) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: `An MCP server with URL "${normalizedServerUrl}" already exists.`, + } satisfies ServiceError; + } + + const existingName = await prisma.mcpServer.findFirst({ + where: { + orgId, + sanitizedName, + }, + select: { id: true }, + }); + if (existingName) { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, + message: 'An MCP server with a similar name already exists. Please choose a more distinct name.', + } satisfies ServiceError; + } + + return { + displayName, + normalizedServerUrl, + sanitizedName, + }; +} + export const checkMcpServerDynamicClientRegistration = async (serverUrl: string) => sew(() => withAuth(async ({ role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -63,81 +200,100 @@ export const checkMcpServerDynamicClientRegistration = async (serverUrl: string) } }))); -export const createMcpServer = async (name: string, serverUrl: string) => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - if (!(await hasEntitlement('oauth'))) { - return oauthNotSupported(); - } +export const createStaticOAuthMcpServer = async ( + body: CreateStaticOAuthMcpServerRequest, +) => { + const parsed = createStaticOAuthMcpServerSchema.safeParse(body); + if (!parsed.success) { + return requestBodySchemaValidationError(parsed.error); + } - const displayName = name.trim(); - const normalizedServerUrl = serverUrl.trim(); - const urlResult = z.string().url().safeParse(normalizedServerUrl); - const protocol = urlResult.success ? new URL(normalizedServerUrl).protocol : undefined; - if (!urlResult.success || protocol !== 'https:') { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Invalid server URL. Must be a valid HTTPS URL.', - } satisfies ServiceError; - } + return sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async (): Promise => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); + } - const sanitizedName = sanitizeMcpServerName(displayName); - const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; - if (alphanumericCount < 3) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Server name must contain at least 3 alphanumeric characters.', - } satisfies ServiceError; - } + const httpsError = await assertHttpsInProduction(); + if (httpsError) { + return httpsError; + } - const existingServer = await prisma.mcpServer.findUnique({ - where: { - serverUrl_orgId: { - serverUrl: normalizedServerUrl, + const preparedServer = await prepareMcpServerCreate({ + prisma, + orgId: org.id, + name: parsed.data.name, + serverUrl: parsed.data.serverUrl, + }); + if (isServiceError(preparedServer)) { + return preparedServer; + } + + const clientInfo = encryptOAuthToken(JSON.stringify({ + client_id: parsed.data.clientId, + client_secret: parsed.data.clientSecret, + })); + if (!clientInfo) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: 'Failed to store OAuth client credentials.', + } satisfies ServiceError; + } + + const mcpServer = await prisma.mcpServer.create({ + data: { + name: preparedServer.displayName, + sanitizedName: preparedServer.sanitizedName, + serverUrl: preparedServer.normalizedServerUrl, + clientInfo, + clientInfoSource: McpServerClientInfoSource.STATIC, orgId: org.id, }, - }, - select: { id: true }, - }); - if (existingServer) { + }); + return { - statusCode: StatusCodes.CONFLICT, - errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: `An MCP server with URL "${normalizedServerUrl}" already exists.`, - } satisfies ServiceError; + id: mcpServer.id, + name: preparedServer.displayName, + sanitizedName: preparedServer.sanitizedName, + serverUrl: mcpServer.serverUrl, + }; + }))); +} + +export const createMcpServer = async (name: string, serverUrl: string) => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!(await hasEntitlement('oauth'))) { + return oauthNotSupported(); } - const existingName = await prisma.mcpServer.findFirst({ - where: { - orgId: org.id, - sanitizedName, - }, - select: { id: true }, + const preparedServer = await prepareMcpServerCreate({ + prisma, + orgId: org.id, + name, + serverUrl, }); - if (existingName) { - return { - statusCode: StatusCodes.CONFLICT, - errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: `An MCP server with a similar name already exists. Please choose a more distinct name.`, - } satisfies ServiceError; + if (isServiceError(preparedServer)) { + return preparedServer; } const mcpServer = await prisma.mcpServer.create({ data: { - name: displayName, - sanitizedName, - serverUrl: normalizedServerUrl, + name: preparedServer.displayName, + sanitizedName: preparedServer.sanitizedName, + serverUrl: preparedServer.normalizedServerUrl, clientInfo: null, + clientInfoSource: McpServerClientInfoSource.DYNAMIC, orgId: org.id, }, }); return { id: mcpServer.id, - name: displayName, - sanitizedName, + name: preparedServer.displayName, + sanitizedName: preparedServer.sanitizedName, serverUrl: mcpServer.serverUrl, }; }))); diff --git a/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts b/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts index 8cd4facbc..194a2a815 100644 --- a/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts +++ b/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts @@ -188,9 +188,9 @@ describe('checkMcpServerDcrSupport', () => { return nestedOidcMetadata.promise; } return notFoundResponse(); - }) as unknown as typeof fetch; + }); - const resultPromise = checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock); + const resultPromise = checkMcpServerDcrSupport('https://mcp.example.com/mcp', fetchMock as unknown as typeof fetch); await vi.waitFor(() => { const requestedUrls = fetchMock.mock.calls.map(([input]) => input.toString()); diff --git a/packages/web/src/ee/features/mcp/externalMcpError.test.ts b/packages/web/src/ee/features/mcp/externalMcpError.test.ts new file mode 100644 index 000000000..5f51433b5 --- /dev/null +++ b/packages/web/src/ee/features/mcp/externalMcpError.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; +import { getExternalMcpErrorLogFields } from './externalMcpError'; + +describe('getExternalMcpErrorLogFields', () => { + test('does not include raw error messages or response bodies', () => { + class OAuthProviderError extends Error { + statusCode = 401; + response = { + status: 401, + body: JSON.stringify({ + error: 'invalid_client', + error_description: 'client_secret=client-secret refresh_token=refresh-token', + }), + }; + } + const error = new OAuthProviderError('invalid_client client_secret=client-secret'); + + const fields = getExternalMcpErrorLogFields(error); + + expect(fields).toEqual({ + errorClass: 'OAuthProviderError', + errorName: 'Error', + oauthError: 'invalid_client', + statusCode: 401, + }); + expect(JSON.stringify(fields)).not.toContain('client-secret'); + expect(JSON.stringify(fields)).not.toContain('refresh-token'); + }); + + test('drops unsafe custom names', () => { + const fields = getExternalMcpErrorLogFields({ + name: 'client_secret=client-secret', + status: 502, + }); + + expect(fields).toEqual({ + errorClass: 'Object', + statusCode: 502, + }); + expect(JSON.stringify(fields)).not.toContain('client-secret'); + }); + + test('preserves known safe diagnostic reasons without raw messages', () => { + const fields = getExternalMcpErrorLogFields( + new Error('Incompatible auth server: does not support dynamic client registration'), + ); + + expect(fields).toEqual({ + errorClass: 'Error', + reason: 'dynamic_client_registration_unsupported', + }); + expect(JSON.stringify(fields)).not.toContain('Incompatible auth server'); + }); + + test('finds allowlisted OAuth codes anywhere in a message', () => { + const fields = getExternalMcpErrorLogFields( + new Error('Request failed at invalid_grant after token exchange'), + ); + + expect(fields).toEqual({ + errorClass: 'Error', + oauthError: 'invalid_grant', + }); + expect(JSON.stringify(fields)).not.toContain('Request failed'); + }); +}); diff --git a/packages/web/src/ee/features/mcp/externalMcpError.ts b/packages/web/src/ee/features/mcp/externalMcpError.ts new file mode 100644 index 000000000..4894a317d --- /dev/null +++ b/packages/web/src/ee/features/mcp/externalMcpError.ts @@ -0,0 +1,174 @@ +interface SafeExternalMcpErrorFields { + errorClass: string; + errorName?: string; + oauthError?: string; + reason?: string; + statusCode?: number; +} + +const OAUTH_ERROR_CODES = new Set([ + 'invalid_request', + 'invalid_client', + 'invalid_grant', + 'unauthorized_client', + 'unsupported_grant_type', + 'invalid_scope', + 'server_error', + 'temporarily_unavailable', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function safeIdentifier(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + if (!/^[A-Za-z0-9_.:-]{1,80}$/.test(value)) { + return undefined; + } + + return value; +} + +function numericStatus(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value)) { + return undefined; + } + + if (value < 100 || value > 599) { + return undefined; + } + + return value; +} + +function getStatusCode(error: unknown): number | undefined { + if (!isRecord(error)) { + return undefined; + } + + return numericStatus(error.statusCode) + ?? numericStatus(error.status) + ?? (isRecord(error.response) ? numericStatus(error.response.status) : undefined); +} + +function safeOAuthErrorCode(value: unknown): string | undefined { + const identifier = safeIdentifier(value); + if (!identifier) { + return undefined; + } + + const normalized = identifier.toLowerCase(); + return OAUTH_ERROR_CODES.has(normalized) ? normalized : undefined; +} + +function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + + return isRecord(error) && typeof error.message === 'string' ? error.message : undefined; +} + +function getConstructorOAuthErrorCode(error: unknown): string | undefined { + if (!isRecord(error)) { + return undefined; + } + + const constructor = error.constructor; + if (!isRecord(constructor)) { + return undefined; + } + + return safeOAuthErrorCode(constructor.errorCode); +} + +function getBodyOAuthErrorCode(body: unknown): string | undefined { + if (typeof body !== 'string' || body.length > 4096) { + return undefined; + } + + try { + const parsed = JSON.parse(body); + return isRecord(parsed) ? safeOAuthErrorCode(parsed.error) : undefined; + } catch { + return undefined; + } +} + +function getMessageOAuthErrorCode(error: unknown): string | undefined { + const tokens = getErrorMessage(error)?.match(/\b[a-z_]{3,40}\b/g); + return tokens?.find((token) => OAUTH_ERROR_CODES.has(token)); +} + +function getOAuthErrorCode(error: unknown): string | undefined { + if (!isRecord(error)) { + return undefined; + } + + return safeOAuthErrorCode(error.error) + ?? safeOAuthErrorCode(error.code) + ?? safeOAuthErrorCode(error.errorCode) + ?? getConstructorOAuthErrorCode(error) + ?? getBodyOAuthErrorCode(error.body) + ?? (isRecord(error.response) ? getBodyOAuthErrorCode(error.response.body) : undefined) + ?? getMessageOAuthErrorCode(error); +} + +function getSafeReason(error: unknown): string | undefined { + const message = getErrorMessage(error)?.toLowerCase(); + if (!message) { + return undefined; + } + + if (message.includes('does not support dynamic client registration')) { + return 'dynamic_client_registration_unsupported'; + } + if (message.includes('does not support grant type')) { + return 'unsupported_grant_type'; + } + if (message.includes('does not support response type')) { + return 'unsupported_response_type'; + } + if (message.includes('does not support code challenge method') || message.includes('does not support s256 code challenge')) { + return 'unsupported_code_challenge_method'; + } + if (message.includes('oauth state parameter mismatch')) { + return 'oauth_state_mismatch'; + } + if (message.includes('oauth client information must be saveable') || message.includes('existing oauth client information is required')) { + return 'missing_oauth_client_information'; + } + + return undefined; +} + +/** + * Returns log-safe metadata for errors thrown by external MCP/OAuth libraries. + * + * Do not log raw error objects, messages, stacks, response bodies, request bodies, + * or causes from these boundaries. A malicious or misconfigured provider can echo + * client secrets or tokens into OAuth error bodies. + */ +export function getExternalMcpErrorLogFields(error: unknown): SafeExternalMcpErrorFields { + const errorClass = error instanceof Error + ? safeIdentifier(error.constructor.name) ?? 'Error' + : safeIdentifier(isRecord(error) ? error.constructor?.name : undefined) ?? 'UnknownExternalMcpError'; + const errorName = error instanceof Error + ? safeIdentifier(error.name) + : safeIdentifier(isRecord(error) ? error.name : undefined); + const oauthError = getOAuthErrorCode(error); + const reason = getSafeReason(error); + const statusCode = getStatusCode(error); + + return { + errorClass, + ...(errorName && errorName !== errorClass ? { errorName } : {}), + ...(oauthError ? { oauthError } : {}), + ...(reason ? { reason } : {}), + ...(statusCode ? { statusCode } : {}), + }; +} diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts index 98c8e6428..996969529 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -3,6 +3,7 @@ import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvi import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { OAuthTokens } from '@ai-sdk/mcp'; import type { PrismaClient } from '@sourcebot/db'; +import { getExternalMcpErrorLogFields } from './externalMcpError'; const logger = createLogger('mcp-client-factory'); @@ -102,7 +103,11 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin transport, }); } catch (error) { - logger.error(`Failed to connect to MCP server ${serverName}:`, error); + logger.error('Failed to prepare MCP server transport.', { + serverId: userServer.serverId, + sanitizedName: userServer.server.sanitizedName, + error: getExternalMcpErrorLogFields(error), + }); } } diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts index ae41bf8e6..ebbdbfacc 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts @@ -4,18 +4,19 @@ import type { McpToolSet } from './mcpClientFactory'; // --- Mocks --- const mockCreateMCPClient = vi.fn(); +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); vi.mock('@ai-sdk/mcp', () => ({ createMCPClient: (...args: unknown[]) => mockCreateMCPClient(...args), })); vi.mock('@sourcebot/shared', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), + createLogger: () => mockLogger, env: { SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 5000, }, @@ -139,7 +140,14 @@ describe('getMcpTools', () => { }); test('failed server connection adds to failedServers array', async () => { - mockCreateMCPClient.mockRejectedValue(new Error('Connection refused')); + const error = new Error('Connection refused client_secret=client-secret access_token=access-token'); + Object.assign(error, { + response: { + status: 502, + body: 'client_secret=client-secret access_token=access-token', + }, + }); + mockCreateMCPClient.mockRejectedValue(error); const result = await getMcpTools([ createMockClient({ serverName: 'BrokenServer' }), @@ -147,6 +155,16 @@ describe('getMcpTools', () => { expect(result.failedServers).toEqual(['BrokenServer']); expect(Object.keys(result.tools)).toEqual([]); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to get tools from MCP server.', { + serverId: 'server-id', + sanitizedName: 'brokenserver', + error: { + errorClass: 'Error', + statusCode: 502, + }, + }); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain('client-secret'); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain('access-token'); }); test('failed server does not prevent other servers from working', async () => { diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index 2ad2277b8..3772d307a 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -4,6 +4,7 @@ import { createLogger, env } from '@sourcebot/shared'; import Ajv from 'ajv'; import { jsonSchema, ToolExecutionOptions } from 'ai'; import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; +import { getExternalMcpErrorLogFields } from './externalMcpError'; const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); @@ -34,7 +35,7 @@ export async function getMcpTools(clients: McpToolSet[]): Promise ({})); vi.mock('@/prisma', () => ({ @@ -82,6 +83,7 @@ describe('clearMcpServerClientCredentialsForObservedClient', () => { id: 'server-1', orgId: 1, clientInfo: 'encrypted-client-info', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, }, data: { clientInfo: null }, }); @@ -113,3 +115,65 @@ describe('clearMcpServerClientCredentialsForObservedClient', () => { expect(prisma.userMcpServer.updateMany).not.toHaveBeenCalled(); }); }); + +describe('PrismaOAuthClientProvider static client information', () => { + test('clientInformation returns static OAuth client credentials', async () => { + const prisma = createPrismaMock(); + prisma.mcpServer.findFirst.mockResolvedValue({ + clientInfo: 'encrypted:{"client_id":"client-id","client_secret":"client-secret"}', + clientInfoSource: McpServerClientInfoSource.STATIC, + }); + const provider = createProvider(prisma); + + await expect(provider.clientInformation()).resolves.toEqual({ + client_id: 'client-id', + client_secret: 'client-secret', + }); + }); + + test('invalidate all preserves static client information and clears only the current user tokens and verifier', async () => { + const prisma = createPrismaMock(); + prisma.mcpServer.findFirst.mockResolvedValue({ + clientInfo: 'encrypted:{"client_id":"client-id","client_secret":"client-secret"}', + clientInfoSource: McpServerClientInfoSource.STATIC, + }); + prisma.mcpServer.updateMany.mockResolvedValue({ count: 0 }); + prisma.userMcpServer.update.mockResolvedValue({ + userId: 'user-1', + serverId: 'server-1', + }); + const provider = createProvider(prisma); + + await provider.clientInformation(); + await provider.invalidateCredentials('all'); + + expect(prisma.mcpServer.updateMany).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + clientInfo: 'encrypted:{"client_id":"client-id","client_secret":"client-secret"}', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }, + data: { clientInfo: null }, + }); + expect(prisma.userMcpServer.updateMany).not.toHaveBeenCalled(); + expect(prisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + tokens: null, + tokensExpiresAt: null, + }, + }); + expect(prisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + codeVerifier: null, + state: null, + }, + }); + }); +}); diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index 9d6f12552..d0a48a99d 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -5,7 +5,7 @@ import type { OAuthClientMetadata, OAuthTokens, } from '@ai-sdk/mcp'; -import type { PrismaClient } from '@sourcebot/db'; +import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; import { __unsafePrisma } from '@/prisma'; @@ -43,6 +43,7 @@ export async function clearMcpServerClientCredentialsForObservedClient({ id: serverId, orgId, clientInfo: observedClientInfo, + clientInfoSource: McpServerClientInfoSource.DYNAMIC, }, data: { clientInfo: null }, }); @@ -79,6 +80,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { private readonly userId: string; private readonly callbackUrl: string; private observedClientInfo: string | undefined; + private observedClientInfoSource: McpServerClientInfoSource | undefined; /** Populated by redirectToAuthorization — read after auth() returns 'REDIRECT'. */ public authorizationUrl: string | undefined; @@ -111,13 +113,17 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { const result = await this.prisma.mcpServer.updateMany({ where: { id: this.serverId, orgId: this.orgId }, - data: { clientInfo: encrypted }, + data: { + clientInfo: encrypted, + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }, }); if (result.count === 0) { throw new Error('MCP server not found'); } this.observedClientInfo = encrypted; + this.observedClientInfoSource = McpServerClientInfoSource.DYNAMIC; }; } } @@ -139,14 +145,19 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { async clientInformation(): Promise { const server = await this.prisma.mcpServer.findFirst({ where: { id: this.serverId, orgId: this.orgId }, - select: { clientInfo: true }, + select: { + clientInfo: true, + clientInfoSource: true, + }, }); if (!server?.clientInfo) { this.observedClientInfo = undefined; + this.observedClientInfoSource = undefined; return undefined; } this.observedClientInfo = server.clientInfo; + this.observedClientInfoSource = server.clientInfoSource; const decrypted = decryptOAuthToken(server.clientInfo); return decrypted ? JSON.parse(decrypted) : undefined; } @@ -222,12 +233,19 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } if (scope === 'all' || scope === 'client') { - await clearMcpServerClientCredentialsForObservedClient({ + const didClearDynamicClient = await clearMcpServerClientCredentialsForObservedClient({ prisma: this.clientInvalidationPrisma, serverId: this.serverId, orgId: this.orgId, observedClientInfo: this.observedClientInfo, }); + if ( + scope === 'all' && + !didClearDynamicClient && + this.observedClientInfoSource === McpServerClientInfoSource.STATIC + ) { + await this.updateUserServer({ tokens: null, tokensExpiresAt: null }); + } } if (scope === 'tokens') { From 95e2d951b3577447546ed22e5921001d4c1197ea Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 20:44:58 -0700 Subject: [PATCH 11/40] feat(web): add more prefab MCP servers --- .../ee/features/mcp/prefabMcpServers.test.ts | 25 +++++++++++++++++-- .../src/ee/features/mcp/prefabMcpServers.ts | 15 +++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts index 48b8d2478..dd452392c 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts @@ -6,8 +6,23 @@ import { } from './prefabMcpServers'; describe('prefab MCP servers', () => { - test('ships Slack as the initial prefab server', () => { + test('ships the supported prefab servers', () => { expect(PREFAB_MCP_SERVERS).toEqual([ + { + id: 'confluence', + name: 'Confluence', + serverUrl: 'https://mcp.atlassian.com/v1/mcp/authv2', + }, + { + id: 'jira', + name: 'Jira', + serverUrl: 'https://mcp.atlassian.com/v1/mcp/authv2', + }, + { + id: 'linear', + name: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + }, { id: 'slack', name: 'Slack', @@ -25,7 +40,13 @@ describe('prefab MCP servers', () => { test('hides already configured prefab servers after URL normalization', () => { const availableServers = getAvailablePrefabMcpServers(['https://mcp.slack.com/mcp/']); - expect(availableServers).toEqual([]); + expect(availableServers.map((server) => server.id)).toEqual(['confluence', 'jira', 'linear']); + }); + + test('hides both Atlassian prefab entries when the shared endpoint is configured', () => { + const availableServers = getAvailablePrefabMcpServers(['https://mcp.atlassian.com/v1/mcp/authv2/']); + + expect(availableServers.map((server) => server.id)).toEqual(['linear', 'slack']); }); test('normalizes server URLs for duplicate comparisons', () => { diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.ts index 85dda6acd..9d4fcb6f0 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.ts @@ -5,6 +5,21 @@ export interface PrefabMcpServer { } const prefabMcpServers = [ + { + id: "confluence", + name: "Confluence", + serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2", + }, + { + id: "jira", + name: "Jira", + serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2", + }, + { + id: "linear", + name: "Linear", + serverUrl: "https://mcp.linear.app/mcp", + }, { id: "slack", name: "Slack", From e71e25f3f68e46748349b23517d48367c929a359 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 20:59:14 -0700 Subject: [PATCH 12/40] fix(web): use official Atlassian MCP icons --- .../prefabMcpServerPopover.tsx | 2 +- .../(server)/ee/askmcp/configuration/route.ts | 2 +- .../api/(server)/ee/askmcp/servers/route.ts | 2 +- .../web/src/ee/features/mcp/mcpToolSets.ts | 7 +++- .../web/src/ee/features/mcp/utils.test.ts | 8 ++++ packages/web/src/ee/features/mcp/utils.ts | 42 ++++++++++++++++++- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx index 895f51f21..f09ba07c9 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx @@ -100,7 +100,7 @@ export function PrefabMcpServerPopover({ className="cursor-pointer" >
- +

{server.name}

diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts index 9a3901108..303418a82 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -51,7 +51,7 @@ export const GET = apiHandler(async (_request: NextRequest) => { const savedConnectionCount = countByServerId.get(server.id) ?? 0; return { ...server, - faviconUrl: getMcpFaviconUrl(server.serverUrl), + faviconUrl: getMcpFaviconUrl(server.serverUrl, server.name), savedConnectionCount, }; }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index aaaa005cd..8fe277379 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -53,7 +53,7 @@ export const GET = apiHandler(async (_request: NextRequest) => { return orgServers.map((server): McpServerWithStatus => { const userServer = userServerByServerId.get(server.id); - const faviconUrl = getMcpFaviconUrl(server.serverUrl); + const faviconUrl = getMcpFaviconUrl(server.serverUrl, server.name); let isConnected = false; let isAuthExpired = false; diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index 3772d307a..febae502c 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -5,6 +5,7 @@ import Ajv from 'ajv'; import { jsonSchema, ToolExecutionOptions } from 'ai'; import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import { getExternalMcpErrorLogFields } from './externalMcpError'; +import { getMcpFaviconUrl } from './utils'; const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); @@ -124,8 +125,10 @@ export async function getMcpTools(clients: McpToolSet[]): Promise { expect(getMcpFaviconUrl('https://mcp.linear.app/mcp')).toBe('https://www.google.com/s2/favicons?domain=https://mcp.linear.app&sz=32'); }); + test('returns local product icons for known shared MCP endpoints', () => { + expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Confluence')).toMatch(/^data:image\/svg\+xml,/); + expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Jira')).toMatch(/^data:image\/svg\+xml,/); + expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Confluence')).not.toBe( + getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Jira'), + ); + }); + test('returns undefined for a malformed server URL', () => { expect(getMcpFaviconUrl('not a url')).toBeUndefined(); }); diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts index 4997c2745..2c9acbadb 100644 --- a/packages/web/src/ee/features/mcp/utils.ts +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -10,7 +10,47 @@ export function sanitizeMcpServerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '_'); } -export function getMcpFaviconUrl(serverUrl: string): string | undefined { +function createMcpProductIconDataUri(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +const confluenceIconSvg = ` + + + + + + + + + +`; + +const jiraIconSvg = ` + + + + + + + + + +`; + +const knownMcpFaviconUrlsBySanitizedName: Record = { + confluence: createMcpProductIconDataUri(confluenceIconSvg), + jira: createMcpProductIconDataUri(jiraIconSvg), +}; + +export function getMcpFaviconUrl(serverUrl: string, serverName?: string): string | undefined { + if (serverName) { + const knownFaviconUrl = knownMcpFaviconUrlsBySanitizedName[sanitizeMcpServerName(serverName)]; + if (knownFaviconUrl) { + return knownFaviconUrl; + } + } + try { const origin = new URL(serverUrl).origin; return `https://www.google.com/s2/favicons?domain=${origin}&sz=32`; From d7e1e7ddb1a13487bd6d388969426a96f7fdd7b0 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 21:18:32 -0700 Subject: [PATCH 13/40] fix(web): use Atlassian prefab MCP server --- .../ee/features/mcp/prefabMcpServers.test.ts | 13 +++----- .../src/ee/features/mcp/prefabMcpServers.ts | 9 ++---- .../web/src/ee/features/mcp/utils.test.ts | 8 ++--- packages/web/src/ee/features/mcp/utils.ts | 31 +++++++------------ 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts index dd452392c..18abdb0a1 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts @@ -9,13 +9,8 @@ describe('prefab MCP servers', () => { test('ships the supported prefab servers', () => { expect(PREFAB_MCP_SERVERS).toEqual([ { - id: 'confluence', - name: 'Confluence', - serverUrl: 'https://mcp.atlassian.com/v1/mcp/authv2', - }, - { - id: 'jira', - name: 'Jira', + id: 'atlassian', + name: 'Atlassian', serverUrl: 'https://mcp.atlassian.com/v1/mcp/authv2', }, { @@ -40,10 +35,10 @@ describe('prefab MCP servers', () => { test('hides already configured prefab servers after URL normalization', () => { const availableServers = getAvailablePrefabMcpServers(['https://mcp.slack.com/mcp/']); - expect(availableServers.map((server) => server.id)).toEqual(['confluence', 'jira', 'linear']); + expect(availableServers.map((server) => server.id)).toEqual(['atlassian', 'linear']); }); - test('hides both Atlassian prefab entries when the shared endpoint is configured', () => { + test('hides the Atlassian prefab entry when the shared endpoint is configured', () => { const availableServers = getAvailablePrefabMcpServers(['https://mcp.atlassian.com/v1/mcp/authv2/']); expect(availableServers.map((server) => server.id)).toEqual(['linear', 'slack']); diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.ts index 9d4fcb6f0..22b60bd16 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.ts @@ -6,13 +6,8 @@ export interface PrefabMcpServer { const prefabMcpServers = [ { - id: "confluence", - name: "Confluence", - serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2", - }, - { - id: "jira", - name: "Jira", + id: "atlassian", + name: "Atlassian", serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2", }, { diff --git a/packages/web/src/ee/features/mcp/utils.test.ts b/packages/web/src/ee/features/mcp/utils.test.ts index daaf1f324..d3c887fc7 100644 --- a/packages/web/src/ee/features/mcp/utils.test.ts +++ b/packages/web/src/ee/features/mcp/utils.test.ts @@ -40,12 +40,8 @@ describe('getMcpFaviconUrl', () => { expect(getMcpFaviconUrl('https://mcp.linear.app/mcp')).toBe('https://www.google.com/s2/favicons?domain=https://mcp.linear.app&sz=32'); }); - test('returns local product icons for known shared MCP endpoints', () => { - expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Confluence')).toMatch(/^data:image\/svg\+xml,/); - expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Jira')).toMatch(/^data:image\/svg\+xml,/); - expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Confluence')).not.toBe( - getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Jira'), - ); + test('returns a local Atlassian icon for the Atlassian prefab server', () => { + expect(getMcpFaviconUrl('https://mcp.atlassian.com/v1/mcp/authv2', 'Atlassian')).toMatch(/^data:image\/svg\+xml,/); }); test('returns undefined for a malformed server URL', () => { diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts index 2c9acbadb..3cfd4dfeb 100644 --- a/packages/web/src/ee/features/mcp/utils.ts +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -10,37 +10,28 @@ export function sanitizeMcpServerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '_'); } -function createMcpProductIconDataUri(svg: string): string { +function createMcpIconDataUri(svg: string): string { return `data:image/svg+xml,${encodeURIComponent(svg)}`; } -const confluenceIconSvg = ` - - - +const atlassianIconSvg = ` + + + - - - - - -`; - -const jiraIconSvg = ` - - - - - + + + + + `; const knownMcpFaviconUrlsBySanitizedName: Record = { - confluence: createMcpProductIconDataUri(confluenceIconSvg), - jira: createMcpProductIconDataUri(jiraIconSvg), + atlassian: createMcpIconDataUri(atlassianIconSvg), }; export function getMcpFaviconUrl(serverUrl: string, serverName?: string): string | undefined { From 0abe155ae0f4c68b72e05a8464f33d086851fe68 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 25 May 2026 21:45:02 -0700 Subject: [PATCH 14/40] feat(web): connect approved MCP servers from chat --- .../chat/components/mcpOAuthStatusToast.tsx | 48 ++++ packages/web/src/app/(app)/chat/layout.tsx | 7 +- .../mcpConfiguration/mcpConfigurationPage.tsx | 11 - packages/web/src/app/api/(client)/client.ts | 2 +- .../(server)/ee/askmcp/callback/route.test.ts | 52 +++- .../api/(server)/ee/askmcp/callback/route.ts | 46 ++-- .../(server)/ee/askmcp/connect/route.test.ts | 69 +++++- .../api/(server)/ee/askmcp/connect/route.ts | 8 +- .../chatBox/chatBoxPlusButton.test.ts | 17 ++ .../components/chatBox/chatBoxPlusButton.tsx | 233 +++++++++++++++--- .../components/chatBox/chatBoxToolbar.tsx | 2 + packages/web/src/features/chat/constants.ts | 1 + .../src/features/chat/mcpOAuthDraft.test.ts | 84 +++++++ .../web/src/features/chat/mcpOAuthDraft.ts | 217 ++++++++++++++++ packages/web/src/features/chat/utils.ts | 7 +- .../src/features/mcp/mcpOAuthReturnTo.test.ts | 32 +++ .../web/src/features/mcp/mcpOAuthReturnTo.ts | 63 +++++ .../features/mcp/prismaOAuthClientProvider.ts | 7 +- 18 files changed, 829 insertions(+), 77 deletions(-) create mode 100644 packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx create mode 100644 packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.test.ts create mode 100644 packages/web/src/features/chat/mcpOAuthDraft.test.ts create mode 100644 packages/web/src/features/chat/mcpOAuthDraft.ts create mode 100644 packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts create mode 100644 packages/web/src/features/mcp/mcpOAuthReturnTo.ts diff --git a/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx b/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx new file mode 100644 index 000000000..0591a42c1 --- /dev/null +++ b/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; + +export function McpOAuthStatusToast() { + const didHandleStatusRef = useRef(false); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const { toast } = useToast(); + + useEffect(() => { + if (didHandleStatusRef.current) { + return; + } + + const status = searchParams.get('status'); + if (status !== 'connected' && status !== 'error') { + return; + } + + didHandleStatusRef.current = true; + const server = searchParams.get('server'); + const message = searchParams.get('message'); + + if (status === 'connected') { + toast({ description: `Successfully connected${server ? ` to ${server}` : ''}.` }); + } else { + toast({ + title: "Connection failed", + description: message ?? 'Failed to connect MCP server.', + variant: "destructive", + }); + } + + const nextSearchParams = new URLSearchParams(searchParams.toString()); + nextSearchParams.delete('status'); + nextSearchParams.delete('server'); + nextSearchParams.delete('message'); + + const query = nextSearchParams.toString(); + router.replace(`${pathname}${query ? `?${query}` : ''}`, { scroll: false }); + }, [pathname, router, searchParams, toast]); + + return null; +} diff --git a/packages/web/src/app/(app)/chat/layout.tsx b/packages/web/src/app/(app)/chat/layout.tsx index 6f2094209..b4bdcdda5 100644 --- a/packages/web/src/app/(app)/chat/layout.tsx +++ b/packages/web/src/app/(app)/chat/layout.tsx @@ -1,6 +1,8 @@ import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME } from '@/lib/constants'; import { NavigationGuardProvider } from 'next-navigation-guard'; import { cookies } from 'next/headers'; +import { Suspense } from 'react'; +import { McpOAuthStatusToast } from './components/mcpOAuthStatusToast'; import { TutorialDialog } from './components/tutorialDialog'; interface LayoutProps { @@ -14,8 +16,11 @@ export default async function Layout({ children }: LayoutProps) { // @note: we use a navigation guard here since we don't support resuming streams yet. // @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams + + + {children} ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx index 83b5ccefd..dfd50c929 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx @@ -239,17 +239,6 @@ export function McpConfigurationPage() {

)}
-
-
-

Allowed MCP servers

-

- {isOAuthUnavailable - ? "Existing workspace-approved MCP servers are available for cleanup." - : "Sourcebot Ask can use only workspace-approved MCP servers."} -

-
-

Only approved servers

-
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 072b3aceb..22c22e974 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -245,7 +245,7 @@ export const getOffers = async (): Promise => { return result as OffersResponse | ServiceError; } -export const connectMcpToAsk = async (body: { serverId: string }): Promise => { +export const connectMcpToAsk = async (body: { serverId: string; returnTo?: string }): Promise => { const result = await fetch('/api/ee/askmcp/connect', { method: 'POST', headers: { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index 5beceaf58..31ce476fd 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -53,9 +53,16 @@ vi.mock('@ai-sdk/mcp', () => ({ })); const { GET } = await import('./route'); +const { createMcpOAuthState } = await import('@/features/mcp/mcpOAuthReturnTo'); -function createRequest() { - return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/callback?code=code-1&state=state-1', { +function createRequest(state = 'state-1') { + return new NextRequest(`https://sourcebot.example.com/api/ee/askmcp/callback?code=code-1&state=${encodeURIComponent(state)}`, { + method: 'GET', + }); +} + +function createOAuthErrorRequest(state: string) { + return new NextRequest(`https://sourcebot.example.com/api/ee/askmcp/callback?error=access_denied&error_description=Denied&state=${encodeURIComponent(state)}`, { method: 'GET', }); } @@ -77,6 +84,47 @@ beforeEach(() => { }); describe('GET /api/ee/askmcp/callback', () => { + test('redirects successful chat-originated auth back to chat', async () => { + const state = createMcpOAuthState('state-1', '/chat'); + mocks.mcpAuth.mockResolvedValue('AUTHORIZED'); + + const response = await GET(createRequest(state)); + const location = response.headers.get('location'); + const url = new URL(location ?? ''); + + expect(url.pathname).toBe('/chat'); + expect(url.searchParams.get('status')).toBe('connected'); + expect(url.searchParams.get('server')).toBe('Linear'); + expect(mocks.unsafePrisma.userMcpServer.findFirst).toHaveBeenCalledWith({ + where: { + state, + userId: 'user-1', + }, + select: { + serverId: true, + server: { + select: { + orgId: true, + name: true, + serverUrl: true, + }, + }, + }, + }); + }); + + test('redirects denied chat-originated auth back to chat', async () => { + const state = createMcpOAuthState('state-1', '/chat'); + + const response = await GET(createOAuthErrorRequest(state)); + const url = new URL(response.headers.get('location') ?? ''); + + expect(url.pathname).toBe('/chat'); + expect(url.searchParams.get('status')).toBe('error'); + expect(url.searchParams.get('message')).toBe('Denied'); + expect(mocks.mcpAuth).not.toHaveBeenCalled(); + }); + test('redirects with a friendly reconnect error when callback auth cannot complete', async () => { mocks.mcpAuth.mockImplementation(async (provider) => { expect('saveClientInformation' in provider).toBe(false); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index 23d694842..30906ba32 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -11,15 +11,30 @@ import { __unsafePrisma as prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextRequest, NextResponse } from 'next/server'; import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; +import { getMcpOAuthReturnToFromState } from '@/features/mcp/mcpOAuthReturnTo'; const logger = createLogger('mcp-oauth-callback'); const reconnectMessage = 'This MCP server authorization could not be completed. Please reconnect the server.'; +const defaultMcpOAuthReturnTo = '/settings/mcpServers'; -function redirectToSettingsError(message: string) { - const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); - settingsUrl.searchParams.set('status', 'error'); - settingsUrl.searchParams.set('message', message); - return NextResponse.redirect(settingsUrl); +function createMcpOAuthRedirectUrl(returnTo: string | undefined): URL { + return new URL(returnTo ?? defaultMcpOAuthReturnTo, env.AUTH_URL); +} + +function setMcpOAuthStatusParams(url: URL, params: { status: 'connected' | 'error'; server?: string; message?: string }) { + url.searchParams.set('status', params.status); + if (params.server) { + url.searchParams.set('server', params.server); + } + if (params.message) { + url.searchParams.set('message', params.message); + } +} + +function redirectToCallbackError(message: string, returnTo?: string) { + const url = createMcpOAuthRedirectUrl(returnTo); + setMcpOAuthStatusParams(url, { status: 'error', message }); + return NextResponse.redirect(url); } // eslint-disable-next-line authz/require-auth-wrapper -- OAuth redirect callback validates the active session with auth() and filters all queries by userId. @@ -43,14 +58,14 @@ export const GET = apiHandler(async (request: NextRequest) => { const oauthError = searchParams.get('error'); const code = searchParams.get('code'); const state = searchParams.get('state'); + const callbackReturnTo = getMcpOAuthReturnToFromState(state); // Handle OAuth errors (e.g., user cancelled the authorization flow). if (oauthError) { - const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); - settingsUrl.searchParams.set('status', 'error'); + const url = createMcpOAuthRedirectUrl(callbackReturnTo); const errorDescription = searchParams.get('error_description') ?? 'Authorization was cancelled or denied.'; - settingsUrl.searchParams.set('message', errorDescription); - return NextResponse.redirect(settingsUrl); + setMcpOAuthStatusParams(url, { status: 'error', message: errorDescription }); + return NextResponse.redirect(url); } if (!code || !state) { @@ -108,7 +123,6 @@ export const GET = apiHandler(async (request: NextRequest) => { callbackUrl: `${env.AUTH_URL}/api/ee/askmcp/callback`, }); - const settingsUrl = new URL(`/settings/mcpServers`, env.AUTH_URL); let result: Awaited>; try { @@ -128,7 +142,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } catch (cleanupError) { logger.warn(`Failed to clear MCP OAuth verifier for user ${session.user.id}:`, cleanupError); } - return redirectToSettingsError(reconnectMessage); + return redirectToCallbackError(reconnectMessage, callbackReturnTo); } // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. @@ -141,13 +155,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (result === 'AUTHORIZED') { const displayName = userServer.server.name || userServer.server.serverUrl; logger.info(`Successfully authorized MCP server ${displayName} for user ${session.user.id}.`); - settingsUrl.searchParams.set('status', 'connected'); - settingsUrl.searchParams.set('server', displayName); - return NextResponse.redirect(settingsUrl); + const url = createMcpOAuthRedirectUrl(callbackReturnTo); + setMcpOAuthStatusParams(url, { status: 'connected', server: displayName }); + return NextResponse.redirect(url); } // If auth() didn't return AUTHORIZED, something went wrong - settingsUrl.searchParams.set('status', 'error'); - settingsUrl.searchParams.set('message', 'Token exchange failed'); - return NextResponse.redirect(settingsUrl); + return redirectToCallbackError('Token exchange failed', callbackReturnTo); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 0db07a56b..6a379c6de 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -44,12 +44,13 @@ vi.mock('@ai-sdk/mcp', () => ({ })); const { POST } = await import('./route'); +const { getMcpOAuthReturnToFromState } = await import('@/features/mcp/mcpOAuthReturnTo'); -function createRequest() { +function createRequest(body: { serverId: string; returnTo?: string } = { serverId: 'server-1' }) { return new NextRequest('http://localhost/api/ee/askmcp/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ serverId: 'server-1' }), + body: JSON.stringify(body), }); } @@ -141,6 +142,70 @@ describe('POST /api/ee/askmcp/connect', () => { expect(body).toEqual({ authorizationUrl: 'https://oauth.example.com/authorize' }); }); + test('encodes a safe return path into OAuth state', async () => { + const prisma = createPrismaMock(); + const tx = createTransactionMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + mocks.unsafePrisma.$transaction.mockImplementation(async (callback, _options) => callback(tx)); + mocks.mcpAuth.mockImplementation(async (provider) => { + const state = await provider.state(); + expect(getMcpOAuthReturnToFromState(state)).toBe('/chat'); + await provider.saveState(state); + + provider.authorizationUrl = 'https://oauth.example.com/authorize'; + return 'REDIRECT'; + }); + + const response = await POST(createRequest({ serverId: 'server-1', returnTo: '/chat' })); + const body = await response.json(); + + expect(body).toEqual({ authorizationUrl: 'https://oauth.example.com/authorize' }); + expect(tx.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + state: expect.stringContaining('sourcebot_mcp.'), + }, + }); + }); + + test('ignores unsafe return paths', async () => { + const prisma = createPrismaMock(); + const tx = createTransactionMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + mocks.unsafePrisma.$transaction.mockImplementation(async (callback, _options) => callback(tx)); + mocks.mcpAuth.mockImplementation(async (provider) => { + const state = await provider.state(); + expect(getMcpOAuthReturnToFromState(state)).toBeUndefined(); + await provider.saveState(state); + + provider.authorizationUrl = 'https://oauth.example.com/authorize'; + return 'REDIRECT'; + }); + + const response = await POST(createRequest({ serverId: 'server-1', returnTo: 'https://evil.example.com/chat' })); + const body = await response.json(); + + expect(body).toEqual({ authorizationUrl: 'https://oauth.example.com/authorize' }); + expect(tx.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + state: expect.not.stringContaining('sourcebot_mcp.'), + }, + }); + }); + test('sanitizes external OAuth errors before logging', async () => { const prisma = createPrismaMock(); const tx = createTransactionMock(); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 87da5805a..89f02381a 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -15,8 +15,12 @@ import { __unsafePrisma } from '@/prisma'; import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; import { ErrorCode } from '@/lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; +import { normalizeMcpOAuthReturnTo } from '@/features/mcp/mcpOAuthReturnTo'; -const bodySchema = z.object({ serverId: z.string() }); +const bodySchema = z.object({ + serverId: z.string(), + returnTo: z.string().optional(), +}); const logger = createLogger('mcp-connect'); const MCP_AUTH_FETCH_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 30000); const MCP_AUTH_TRANSACTION_MAX_WAIT_MS = 10000; @@ -52,6 +56,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = await sew(() => withAuth(async ({ user, org, prisma }) => { + const callbackReturnTo = normalizeMcpOAuthReturnTo(parsed.data.returnTo); const mcpServer = await prisma.mcpServer.findFirst({ where: { id: parsed.data.serverId, orgId: org.id }, select: { @@ -96,6 +101,7 @@ export const POST = apiHandler(async (request: NextRequest) => { orgId: org.id, userId: user.id, callbackUrl: `${env.AUTH_URL}/api/ee/askmcp/callback`, + callbackReturnTo, allowClientRegistration: true, }); diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.test.ts b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.test.ts new file mode 100644 index 000000000..5170d3c60 --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest'; +import { splitMcpServersForChatMenu } from './chatBoxPlusButton'; + +describe('splitMcpServersForChatMenu', () => { + test('keeps connected and expired servers separate from connectable approved servers', () => { + const servers = [ + { id: 'connected', isConnected: true, isAuthExpired: false }, + { id: 'expired', isConnected: false, isAuthExpired: true }, + { id: 'approved', isConnected: false, isAuthExpired: false }, + ]; + + const { connectedServers, connectableServers } = splitMcpServersForChatMenu(servers); + + expect(connectedServers.map((server) => server.id)).toEqual(['connected', 'expired']); + expect(connectableServers.map((server) => server.id)).toEqual(['approved']); + }); +}); diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx index 4b304f41a..6a484a083 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -12,29 +12,72 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Switch } from "@/components/ui/switch"; -import { getMcpServersWithStatus } from "@/app/api/(client)/client"; +import { connectMcpToAsk, getMcpServersWithStatus } from "@/app/api/(client)/client"; +import { useToast } from "@/components/hooks/use-toast"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AlertTriangleIcon, Plug, PlusIcon, RefreshCwIcon, ServerIcon, SettingsIcon } from "lucide-react"; +import { AlertTriangleIcon, Loader2Icon, PlusCircleIcon, PlusIcon, RefreshCwIcon, ServerIcon, SettingsIcon } from "lucide-react"; import { PlusButtonInfoCard } from "./plusButtonInfoCard"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useSlate } from "slate-react"; +import { Editor } from "slate"; +import type { CustomEditor, SearchScope } from "@/features/chat/types"; +import { + clearMcpOAuthDraft, + consumeMcpOAuthDraftForPath, + createMcpOAuthDraftPath, + saveMcpOAuthDraft, +} from "@/features/chat/mcpOAuthDraft"; +import { clearEditorHistory, resetEditor } from "@/features/chat/utils"; interface ChatBoxPlusButtonProps { + selectedSearchScopes: SearchScope[]; + onSelectedSearchScopesChange: (items: SearchScope[]) => void; disabledMcpServerIds: string[]; onDisabledMcpServerIdsChange: (ids: string[]) => void; } +interface ChatMenuMcpServer { + isConnected: boolean; + isAuthExpired: boolean; +} + +export function splitMcpServersForChatMenu(servers: T[]) { + return { + connectedServers: servers.filter((server) => server.isConnected || server.isAuthExpired), + connectableServers: servers.filter((server) => !server.isConnected && !server.isAuthExpired), + }; +} + +function restoreEditorChildren(editor: CustomEditor, children: CustomEditor['children']) { + editor.children = children; + editor.selection = { + anchor: Editor.end(editor, []), + focus: Editor.end(editor, []), + }; + clearEditorHistory(editor); + editor.onChange(); +} + export const ChatBoxPlusButton = ({ + selectedSearchScopes, + onSelectedSearchScopesChange, disabledMcpServerIds, onDisabledMcpServerIdsChange, }: ChatBoxPlusButtonProps) => { - const [failedFavicons, setFailedFavicons] = useState>(new Set()); + const [connectingServerId, setConnectingServerId] = useState(null); + const editor = useSlate(); + const hasRestoredMcpOAuthDraft = useRef(false); + const isMountedRef = useRef(false); + const queryClient = useQueryClient(); const router = useRouter(); + const { toast } = useToast(); - const { data: servers, isError, refetch } = useQuery({ + const { data: servers = [], isError, isLoading, refetch } = useQuery({ queryKey: mcpQueryKeys.serversWithStatus, queryFn: async () => { const result = await getMcpServersWithStatus(); @@ -45,6 +88,42 @@ export const ChatBoxPlusButton = ({ }, }); + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (hasRestoredMcpOAuthDraft.current) { + return; + } + + const currentPath = createMcpOAuthDraftPath(window.location.pathname, window.location.search); + if (!currentPath) { + return; + } + + const draft = consumeMcpOAuthDraftForPath(currentPath); + if (!draft) { + return; + } + + hasRestoredMcpOAuthDraft.current = true; + + try { + restoreEditorChildren(editor, draft.children); + onSelectedSearchScopesChange(draft.selectedSearchScopes); + onDisabledMcpServerIdsChange(draft.disabledMcpServerIds); + } catch (error) { + resetEditor(editor); + editor.onChange(); + console.error('Failed to restore MCP OAuth draft:', error); + } + }, [editor, onDisabledMcpServerIdsChange, onSelectedSearchScopesChange]); + const onToggle = (serverId: string, checked: boolean) => { if (checked) { onDisabledMcpServerIdsChange(disabledMcpServerIds.filter((id) => id !== serverId)); @@ -53,12 +132,66 @@ export const ChatBoxPlusButton = ({ } }; - const onFaviconError = (serverId: string) => { - setFailedFavicons((prev) => new Set(prev).add(serverId)); + const handleConnect = async (serverId: string) => { + setConnectingServerId(serverId); + const returnTo = createMcpOAuthDraftPath(window.location.pathname, window.location.search) ?? '/chat'; + + saveMcpOAuthDraft({ + returnTo, + children: editor.children, + selectedSearchScopes, + disabledMcpServerIds, + }); + + try { + const result = await connectMcpToAsk({ + serverId, + returnTo, + }); + + if (!isMountedRef.current) { + return; + } + + if (isServiceError(result)) { + clearMcpOAuthDraft(); + toast({ + description: `Failed to connect MCP server. ${result.message}`, + variant: "destructive", + }); + setConnectingServerId(null); + return; + } + + if (result.authorizationUrl) { + window.location.href = result.authorizationUrl; + return; + } + + clearMcpOAuthDraft(); + toast({ description: 'MCP server is already connected.' }); + await queryClient.invalidateQueries({ queryKey: mcpQueryKeys.serversWithStatus }); + if (!isMountedRef.current) { + return; + } + setConnectingServerId(null); + } catch { + if (!isMountedRef.current) { + return; + } + + clearMcpOAuthDraft(); + toast({ + description: "Failed to connect MCP server.", + variant: "destructive", + }); + setConnectingServerId(null); + return; + } }; - // Only surface servers the user has attempted to connect (connected or auth expired). - const relevantServers = servers?.filter((s) => s.isConnected || s.isAuthExpired) ?? []; + const { connectedServers, connectableServers } = splitMcpServersForChatMenu(servers); + const hasServers = connectedServers.length > 0 || connectableServers.length > 0; return ( @@ -85,7 +218,7 @@ export const ChatBoxPlusButton = ({ MCP Servers - {isError && relevantServers.length === 0 ? ( + {isError && !hasServers ? ( { e.preventDefault(); @@ -96,45 +229,65 @@ export const ChatBoxPlusButton = ({ Failed to load. Retry? - ) : relevantServers.length === 0 ? ( + ) : isLoading ? ( - No MCP servers connected + Loading MCP servers... + + ) : !hasServers ? ( + + No MCP servers available ) : ( - relevantServers.map((server) => { - const isEnabled = !server.isAuthExpired && !disabledMcpServerIds.includes(server.id); - return ( + <> + {connectedServers.map((server) => { + const isEnabled = !server.isAuthExpired && !disabledMcpServerIds.includes(server.id); + return ( + e.preventDefault()} + disabled={server.isAuthExpired} + className="flex items-center justify-between gap-2" + > +
+ {server.isAuthExpired ? ( + + ) : ( + + )} + {server.name} +
+ onToggle(server.id, checked)} + disabled={server.isAuthExpired} + className="scale-75" + /> +
+ ); + })} + {connectedServers.length > 0 && connectableServers.length > 0 && } + {connectableServers.map((server) => ( e.preventDefault()} - disabled={server.isAuthExpired} - className="flex items-center justify-between gap-2" + onSelect={(e) => { + e.preventDefault(); + void handleConnect(server.id); + }} + disabled={connectingServerId !== null} + className="group flex cursor-pointer items-center justify-between gap-2" >
- {server.isAuthExpired ? ( - - ) : failedFavicons.has(server.id) ? ( - - ) : ( - // eslint-disable-next-line @next/next/no-img-element - onFaviconError(server.id)} - className="w-4 h-4 shrink-0 rounded-sm" - alt="" - /> - )} + {server.name}
- onToggle(server.id, checked)} - disabled={server.isAuthExpired} - className="scale-75" - /> + {connectingServerId === server.id ? ( + + ) : ( + + )}
- ); - }) + ))} + )} diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index 47e3f58cf..db518d2aa 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -11,3 +11,4 @@ export const SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY = 'selectedSearchScopes'; export const SET_CHAT_STATE_SESSION_STORAGE_KEY = 'setChatState'; export const PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY = 'pendingChatSubmission'; export const DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY = 'disabledMcpServerIds'; +export const MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY = 'mcpOAuthDraft'; diff --git a/packages/web/src/features/chat/mcpOAuthDraft.test.ts b/packages/web/src/features/chat/mcpOAuthDraft.test.ts new file mode 100644 index 000000000..93c9281c3 --- /dev/null +++ b/packages/web/src/features/chat/mcpOAuthDraft.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY } from './constants'; +import { + consumeMcpOAuthDraftForPath, + normalizeMcpOAuthDraftPath, + resolveMcpOAuthDraftForPath, + saveMcpOAuthDraft, +} from './mcpOAuthDraft'; +import type { Descendant } from 'slate'; +import type { SearchScope } from './types'; + +const children = [{ + type: 'paragraph', + children: [{ text: 'check the Linear ticket' }], +}] satisfies Descendant[]; + +const selectedSearchScopes = [{ + type: 'repo', + value: 'sourcebot/sourcebot', + name: 'sourcebot/sourcebot', + codeHostType: 'github', +}] satisfies SearchScope[]; + +const draft = { + returnTo: '/chat/thread-1?scope=sourcebot', + children, + selectedSearchScopes, + disabledMcpServerIds: ['server-disabled'], + createdAt: 100, +}; + +describe('MCP OAuth draft persistence', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('normalizes chat paths and strips OAuth status params', () => { + expect(normalizeMcpOAuthDraftPath('/chat/thread-1?scope=sourcebot&status=connected&server=Linear')).toBe('/chat/thread-1?scope=sourcebot'); + expect(normalizeMcpOAuthDraftPath('/settings/mcpServers')).toBeUndefined(); + expect(normalizeMcpOAuthDraftPath('https://evil.example.com/chat')).toBeUndefined(); + expect(normalizeMcpOAuthDraftPath('//evil.example.com/chat')).toBeUndefined(); + }); + + test('resolves a draft for the same chat path after the OAuth callback adds status params', () => { + const result = resolveMcpOAuthDraftForPath( + JSON.stringify(draft), + '/chat/thread-1?scope=sourcebot&status=connected&server=Linear', + 200, + ); + + expect(result.shouldClear).toBe(true); + expect(result.draft).toEqual(draft); + }); + + test('keeps a draft when the current chat path does not match', () => { + const result = resolveMcpOAuthDraftForPath(JSON.stringify(draft), '/chat/thread-2', 200); + + expect(result.shouldClear).toBe(false); + expect(result.draft).toBeUndefined(); + }); + + test('clears invalid and stale drafts', () => { + expect(resolveMcpOAuthDraftForPath('{', '/chat/thread-1').shouldClear).toBe(true); + expect(resolveMcpOAuthDraftForPath(JSON.stringify({ ...draft, children: [1] }), '/chat/thread-1?scope=sourcebot', 200).shouldClear).toBe(true); + expect(resolveMcpOAuthDraftForPath(JSON.stringify(draft), '/chat/thread-1?scope=sourcebot', 30 * 60 * 1000 + 101).shouldClear).toBe(true); + }); + + test('saves and consumes the composer draft from sessionStorage', () => { + saveMcpOAuthDraft({ + returnTo: '/chat/thread-1?scope=sourcebot&status=error', + children, + selectedSearchScopes, + disabledMcpServerIds: ['server-disabled'], + }); + + const restoredDraft = consumeMcpOAuthDraftForPath('/chat/thread-1?scope=sourcebot&status=connected&server=Linear'); + + expect(restoredDraft?.returnTo).toBe('/chat/thread-1?scope=sourcebot'); + expect(restoredDraft?.children).toEqual(children); + expect(restoredDraft?.selectedSearchScopes).toEqual(selectedSearchScopes); + expect(restoredDraft?.disabledMcpServerIds).toEqual(['server-disabled']); + expect(sessionStorage.getItem(MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/packages/web/src/features/chat/mcpOAuthDraft.ts b/packages/web/src/features/chat/mcpOAuthDraft.ts new file mode 100644 index 000000000..19f00f84f --- /dev/null +++ b/packages/web/src/features/chat/mcpOAuthDraft.ts @@ -0,0 +1,217 @@ +import type { Descendant } from "slate"; +import { MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY } from "./constants"; +import type { CustomText, MentionElement, ParagraphElement, SearchScope } from "./types"; + +const MCP_OAUTH_DRAFT_BASE_URL = 'https://sourcebot.local'; +const MCP_OAUTH_DRAFT_MAX_AGE_MS = 30 * 60 * 1000; +const MCP_OAUTH_STATUS_PARAMS = ['status', 'server', 'message']; + +export interface McpOAuthDraft { + returnTo: string; + children: Descendant[]; + selectedSearchScopes: SearchScope[]; + disabledMcpServerIds: string[]; + createdAt: number; +} + +type McpOAuthDraftInput = Omit; + +interface ResolveMcpOAuthDraftResult { + draft?: McpOAuthDraft; + shouldClear: boolean; +} + +function isAllowedMcpOAuthDraftPath(pathname: string): boolean { + return pathname === '/chat' || pathname.startsWith('/chat/'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isCustomText(value: unknown): value is CustomText { + return isRecord(value) && typeof value.text === 'string'; +} + +function isMentionElement(value: unknown): value is MentionElement { + return ( + isRecord(value) && + value.type === 'mention' && + isRecord(value.data) && + value.data.type === 'file' && + typeof value.data.repo === 'string' && + typeof value.data.path === 'string' && + typeof value.data.name === 'string' && + typeof value.data.language === 'string' && + typeof value.data.revision === 'string' && + Array.isArray(value.children) && + value.children.every(isCustomText) + ); +} + +function isParagraphElement(value: unknown): value is ParagraphElement { + return ( + isRecord(value) && + value.type === 'paragraph' && + (value.align === undefined || typeof value.align === 'string') && + Array.isArray(value.children) && + value.children.length > 0 && + value.children.every((child) => isCustomText(child) || isMentionElement(child)) + ); +} + +function isMcpOAuthDraftChildren(value: unknown): value is Descendant[] { + return Array.isArray(value) && value.length > 0 && value.every(isParagraphElement); +} + +export function normalizeMcpOAuthDraftPath(path: string): string | undefined { + const trimmedPath = path.trim(); + if (!trimmedPath || !trimmedPath.startsWith('/') || trimmedPath.startsWith('//') || trimmedPath.includes('\\')) { + return undefined; + } + + try { + const url = new URL(trimmedPath, MCP_OAUTH_DRAFT_BASE_URL); + if (url.origin !== MCP_OAUTH_DRAFT_BASE_URL || !isAllowedMcpOAuthDraftPath(url.pathname)) { + return undefined; + } + + for (const param of MCP_OAUTH_STATUS_PARAMS) { + url.searchParams.delete(param); + } + + const query = url.searchParams.toString(); + return `${url.pathname}${query ? `?${query}` : ''}`; + } catch { + return undefined; + } +} + +export function createMcpOAuthDraftPath(pathname: string, search: string): string | undefined { + return normalizeMcpOAuthDraftPath(`${pathname}${search}`); +} + +function isMcpOAuthDraft(value: unknown): value is McpOAuthDraft { + return ( + isRecord(value) && + 'returnTo' in value && + typeof value.returnTo === 'string' && + 'children' in value && + isMcpOAuthDraftChildren(value.children) && + 'selectedSearchScopes' in value && + Array.isArray(value.selectedSearchScopes) && + 'disabledMcpServerIds' in value && + Array.isArray(value.disabledMcpServerIds) && + value.disabledMcpServerIds.every((id) => typeof id === 'string') && + 'createdAt' in value && + typeof value.createdAt === 'number' + ); +} + +export function resolveMcpOAuthDraftForPath( + storedDraft: string | null, + currentPath: string, + now = Date.now(), +): ResolveMcpOAuthDraftResult { + if (!storedDraft) { + return { shouldClear: false }; + } + + let parsedDraft: unknown; + try { + parsedDraft = JSON.parse(storedDraft); + } catch { + return { shouldClear: true }; + } + + if (!isMcpOAuthDraft(parsedDraft)) { + return { shouldClear: true }; + } + + if (now - parsedDraft.createdAt > MCP_OAUTH_DRAFT_MAX_AGE_MS) { + return { shouldClear: true }; + } + + const storedPath = normalizeMcpOAuthDraftPath(parsedDraft.returnTo); + if (!storedPath) { + return { shouldClear: true }; + } + + const normalizedCurrentPath = normalizeMcpOAuthDraftPath(currentPath); + if (!normalizedCurrentPath) { + return { shouldClear: false }; + } + + if (storedPath !== normalizedCurrentPath) { + return { shouldClear: false }; + } + + return { + draft: { + ...parsedDraft, + returnTo: storedPath, + }, + shouldClear: true, + }; +} + +function getSessionStorage(): Storage | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return window.sessionStorage; + } catch { + return undefined; + } +} + +export function saveMcpOAuthDraft(draft: McpOAuthDraftInput): void { + const storage = getSessionStorage(); + const returnTo = normalizeMcpOAuthDraftPath(draft.returnTo); + if (!storage || !returnTo) { + return; + } + + try { + storage.setItem(MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY, JSON.stringify({ + ...draft, + returnTo, + createdAt: Date.now(), + } satisfies McpOAuthDraft)); + } catch { + // If sessionStorage is unavailable or full, OAuth should still proceed. + } +} + +export function clearMcpOAuthDraft(): void { + const storage = getSessionStorage(); + if (!storage) { + return; + } + + try { + storage.removeItem(MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY); + } catch { + // Ignore storage cleanup failures. + } +} + +export function consumeMcpOAuthDraftForPath(currentPath: string): McpOAuthDraft | undefined { + const storage = getSessionStorage(); + if (!storage) { + return undefined; + } + + const result = resolveMcpOAuthDraftForPath( + storage.getItem(MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY), + currentPath, + ); + + if (result.shouldClear) { + clearMcpOAuthDraft(); + } + + return result.draft; +} diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index 2ecccd727..cdcd1c0e0 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -161,11 +161,16 @@ export const getAllMentionElements = (children: Descendant[]): MentionElement[] }); } +export const clearEditorHistory = (editor: CustomEditor) => { + // slate-history exposes `history` publicly, but does not provide a clear API. + editor.history = { redos: [], undos: [] }; +} + // @see: https://stackoverflow.com/a/74102147 export const resetEditor = (editor: CustomEditor) => { const point = { path: [0, 0], offset: 0 } editor.selection = { anchor: point, focus: point }; - editor.history = { redos: [], undos: [] }; + clearEditorHistory(editor); editor.children = [{ type: "paragraph", children: [{ text: "" }] diff --git a/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts b/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts new file mode 100644 index 000000000..321d9ee3d --- /dev/null +++ b/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest'; +import { + createMcpOAuthState, + getMcpOAuthReturnToFromState, + normalizeMcpOAuthReturnTo, +} from './mcpOAuthReturnTo'; + +describe('MCP OAuth return paths', () => { + test('allows chat return paths', () => { + expect(normalizeMcpOAuthReturnTo('/chat')).toBe('/chat'); + expect(normalizeMcpOAuthReturnTo('/chat/thread-1?foo=bar')).toBe('/chat/thread-1?foo=bar'); + }); + + test('rejects external and unrelated return paths', () => { + expect(normalizeMcpOAuthReturnTo('https://evil.example.com/chat')).toBeUndefined(); + expect(normalizeMcpOAuthReturnTo('//evil.example.com/chat')).toBeUndefined(); + expect(normalizeMcpOAuthReturnTo('/settings')).toBeUndefined(); + }); + + test('encodes and decodes return paths inside OAuth state', () => { + const state = createMcpOAuthState('nonce-1', '/chat'); + + expect(state).not.toBe('nonce-1'); + expect(getMcpOAuthReturnToFromState(state)).toBe('/chat'); + }); + + test('leaves state unchanged when no valid return path exists', () => { + expect(createMcpOAuthState('nonce-1')).toBe('nonce-1'); + expect(createMcpOAuthState('nonce-1', '/settings')).toBe('nonce-1'); + expect(getMcpOAuthReturnToFromState('nonce-1')).toBeUndefined(); + }); +}); diff --git a/packages/web/src/features/mcp/mcpOAuthReturnTo.ts b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts new file mode 100644 index 000000000..e46b5805e --- /dev/null +++ b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts @@ -0,0 +1,63 @@ +const MCP_OAUTH_STATE_PREFIX = 'sourcebot_mcp.'; +const MCP_OAUTH_STATE_BASE_URL = 'https://sourcebot.local'; + +function isAllowedMcpOAuthReturnPath(pathname: string): boolean { + return pathname === '/chat' || pathname.startsWith('/chat/') || pathname === '/settings/mcpServers'; +} + +export function normalizeMcpOAuthReturnTo(returnTo: unknown): string | undefined { + if (typeof returnTo !== 'string') { + return undefined; + } + + const trimmedReturnTo = returnTo.trim(); + if (!trimmedReturnTo || !trimmedReturnTo.startsWith('/') || trimmedReturnTo.startsWith('//') || trimmedReturnTo.includes('\\')) { + return undefined; + } + + try { + const url = new URL(trimmedReturnTo, MCP_OAUTH_STATE_BASE_URL); + if (url.origin !== MCP_OAUTH_STATE_BASE_URL || !isAllowedMcpOAuthReturnPath(url.pathname)) { + return undefined; + } + + return `${url.pathname}${url.search}`; + } catch { + return undefined; + } +} + +export function createMcpOAuthState(nonce: string, returnTo?: string): string { + const normalizedReturnTo = normalizeMcpOAuthReturnTo(returnTo); + if (!normalizedReturnTo) { + return nonce; + } + + const encoded = Buffer.from(JSON.stringify({ + nonce, + returnTo: normalizedReturnTo, + })).toString('base64url'); + return `${MCP_OAUTH_STATE_PREFIX}${encoded}`; +} + +export function getMcpOAuthReturnToFromState(state: string | null | undefined): string | undefined { + if (!state?.startsWith(MCP_OAUTH_STATE_PREFIX)) { + return undefined; + } + + try { + const encoded = state.slice(MCP_OAUTH_STATE_PREFIX.length); + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as unknown; + if ( + typeof payload === 'object' && + payload !== null && + 'returnTo' in payload + ) { + return normalizeMcpOAuthReturnTo(payload.returnTo); + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index d0a48a99d..3f5446b40 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -8,6 +8,7 @@ import type { import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; import { __unsafePrisma } from '@/prisma'; +import { createMcpOAuthState } from './mcpOAuthReturnTo'; type McpOAuthPrismaClient = Pick; @@ -17,6 +18,7 @@ interface PrismaOAuthClientProviderOptions { orgId: number; userId: string; callbackUrl: string; + callbackReturnTo?: string; allowClientRegistration?: boolean; clientInvalidationPrisma?: McpOAuthPrismaClient; } @@ -79,6 +81,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { private readonly orgId: number; private readonly userId: string; private readonly callbackUrl: string; + private readonly callbackReturnTo: string | undefined; private observedClientInfo: string | undefined; private observedClientInfoSource: McpServerClientInfoSource | undefined; @@ -94,6 +97,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { orgId, userId, callbackUrl, + callbackReturnTo, allowClientRegistration = false, clientInvalidationPrisma = __unsafePrisma, }: PrismaOAuthClientProviderOptions) { @@ -103,6 +107,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { this.orgId = orgId; this.userId = userId; this.callbackUrl = callbackUrl; + this.callbackReturnTo = callbackReturnTo; if (allowClientRegistration) { this.saveClientInformation = async (info: OAuthClientInformation) => { @@ -197,7 +202,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async state(): Promise { - return crypto.randomUUID(); + return createMcpOAuthState(crypto.randomUUID(), this.callbackReturnTo); } async saveState(state: string): Promise { From 4d1bcfc6667478b461598101e1a386462727bc36 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Tue, 26 May 2026 11:36:32 -0700 Subject: [PATCH 15/40] feat(web): redesign MCP servers settings page Rework the MCP servers page with a cleaner, more compact layout: - Split servers into Connected / Suggested sections - Add search bar with All / Connected filter tabs - Compact card design with smaller favicons, stripped URLs, quieter status indicators - Move Reconnect into three-dot overflow menu alongside new Disconnect option - Add disconnectMcpServer server action to remove a user's MCP credentials - Extract useConnectMcp hook for shared connect/reconnect logic --- .../settings/mcpServers/mcpServersPage.tsx | 377 +++++++++++++++--- packages/web/src/ee/features/mcp/actions.ts | 36 ++ .../mcp/components/connectMcpButton.tsx | 51 +-- .../ee/features/mcp/hooks/useConnectMcp.ts | 39 ++ 4 files changed, 406 insertions(+), 97 deletions(-) create mode 100644 packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx index f34bcca52..d06239bfb 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx +++ b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx @@ -1,18 +1,38 @@ 'use client'; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { Settings2Icon, ServerIcon } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, MoreHorizontal, SearchIcon, ServerIcon, Settings2Icon, Unplug } from "lucide-react"; import { getMcpServersWithStatus } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; -import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { isServiceError } from "@/lib/utils"; +import { useConnectMcp } from "@/ee/features/mcp/hooks/useConnectMcp"; +import { disconnectMcpServer } from "@/ee/features/mcp/actions"; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { cn, isServiceError } from "@/lib/utils"; + +type FilterTab = "all" | "connected"; + +function displayUrl(url: string) { + return url.replace(/^https?:\/\//, ""); +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} function clearCallbackParams() { const url = new URL(window.location.href); @@ -59,7 +79,13 @@ export function McpServersEmptyState({ canManageMcpServers }: { canManageMcpServ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage, canManageMcpServers }: McpServersPageProps) { const { toast } = useToast(); + const queryClient = useQueryClient(); const didHandleCallbackRef = useRef(false); + const [searchQuery, setSearchQuery] = useState(""); + const [activeTab, setActiveTab] = useState("all"); + const [disconnectingServerId, setDisconnectingServerId] = useState(null); + const [confirmDisconnectServer, setConfirmDisconnectServer] = useState<{ id: string; name: string } | null>(null); + const { connect: reconnectMcp } = useConnectMcp(); useEffect(() => { if (didHandleCallbackRef.current) { @@ -87,10 +113,77 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage }, }); + const connectedServers = useMemo( + () => servers.filter((s) => s.isConnected || s.isAuthExpired), + [servers], + ); + + const suggestedServers = useMemo( + () => servers.filter((s) => !s.isConnected && !s.isAuthExpired), + [servers], + ); + + const filteredConnected = useMemo(() => { + const list = connectedServers; + if (!searchQuery.trim()) { + return list; + } + const q = searchQuery.toLowerCase(); + return list.filter( + (s) => (s.name?.toLowerCase().includes(q)) || s.serverUrl.toLowerCase().includes(q), + ); + }, [connectedServers, searchQuery]); + + const filteredSuggested = useMemo(() => { + const list = suggestedServers; + if (!searchQuery.trim()) { + return list; + } + const q = searchQuery.toLowerCase(); + return list.filter( + (s) => (s.name?.toLowerCase().includes(q)) || s.serverUrl.toLowerCase().includes(q), + ); + }, [suggestedServers, searchQuery]); + + const visibleConnected = filteredConnected; + const visibleSuggested = activeTab === "all" ? filteredSuggested : []; + + const handleDisconnect = async (serverId: string) => { + setDisconnectingServerId(serverId); + setConfirmDisconnectServer(null); + try { + const result = await disconnectMcpServer(serverId); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to disconnect: ${result.message}`, variant: "destructive" }); + return; + } + toast({ description: "MCP server disconnected." }); + await invalidateMcpConfigurationQueries(queryClient); + } catch { + toast({ title: "Error", description: "Failed to disconnect MCP server.", variant: "destructive" }); + } finally { + setDisconnectingServerId(null); + } + }; + if (isError) { return
Error loading MCP servers
; } + if (!isLoading && servers.length === 0) { + return ( +
+
+

MCP Servers

+

+ Connect to workspace-approved MCP servers to use them with Ask Sourcebot. +

+
+ +
+ ); + } + return (
@@ -100,68 +193,232 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage

+ {/* Search + filter bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + +
+
+ {isLoading ? ( -
- {Array.from({ length: 2 }).map((_, index) => ( +
+ {Array.from({ length: 3 }).map((_, index) => ( - - - - - - - - - ))} -
- ) : servers.length === 0 ? ( - - ) : ( -
- {servers.map((server) => ( - - -
-
- -
- {server.name || server.serverUrl} - {server.serverUrl} -
-
+ + +
+ +
- - - {server.isConnected && ( -
- - Connected -
- )} - {server.isAuthExpired && ( -
- - Authorization expired -
- )} - {!server.isConnected && !server.isAuthExpired && ( -
- - Not connected -
- )} +
- - - ))}
+ ) : ( + <> + {/* Connected section */} +
+
+

+ Connected +

+

+ {connectedServers.length} {pluralize(connectedServers.length, "server")} +

+
+ + {visibleConnected.length === 0 ? ( + + +

+ {searchQuery.trim() + ? "No connected servers match your search." + : "No servers connected yet."} +

+
+
+ ) : ( + visibleConnected.map((server) => ( + + +
+ +
+
+

+ {server.name || server.serverUrl} +

+

+ {displayUrl(server.serverUrl)} +

+
+ {server.isConnected && ( + <> + + Connected + + )} + {server.isAuthExpired && ( + <> + + Authorization expired + + )} +
+
+
+ + + + + + + reconnectMcp(server.id)}> + + Reconnect + + setConfirmDisconnectServer({ + id: server.id, + name: server.name || server.serverUrl, + })} + > + + {disconnectingServerId === server.id ? "Disconnecting..." : "Disconnect"} + + + +
+
+
+ )) + )} +
+ + {/* Suggested section */} + {activeTab === "all" && ( +
+
+

+ Suggested +

+

+ workspace-approved +

+
+ + {visibleSuggested.length === 0 ? ( + + +

+ {searchQuery.trim() + ? "No suggested servers match your search." + : "All servers are connected."} +

+
+
+ ) : ( + visibleSuggested.map((server) => ( + + +
+ +
+
+

+ {server.name || server.serverUrl} +

+

+ {displayUrl(server.serverUrl)} +

+
+ +
+
+ )) + )} +
+ )} + )} + + {/* Disconnect confirmation dialog */} + { + if (!open) { + setConfirmDisconnectServer(null); + } + }} + > + + + Disconnect MCP Server + + Are you sure you want to disconnect from {confirmDisconnectServer?.name}? Your stored credentials for this server will be removed. + + + + Cancel + { + if (confirmDisconnectServer) { + handleDisconnect(confirmDisconnectServer.id); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Disconnect + + + +
); } diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index f000bb81b..ebe2470c6 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -318,3 +318,39 @@ export const deleteMcpServer = async (serverId: string) => sew(() => return { success: true }; }))); + +export const disconnectMcpServer = async (serverId: string) => sew(() => + withAuth(async ({ org, user }) => { + const server = await __unsafePrisma.mcpServer.findFirst({ + where: { + id: serverId, + orgId: org.id, + }, + select: { id: true }, + }); + + if (!server) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + message: 'MCP server not found', + } satisfies ServiceError; + } + + const result = await __unsafePrisma.userMcpServer.deleteMany({ + where: { + serverId, + userId: user.id, + }, + }); + + if (result.count === 0) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + message: 'No connection found for this MCP server.', + } satisfies ServiceError; + } + + return { success: true }; + })); diff --git a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx index d2b00c516..417392734 100644 --- a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx +++ b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx @@ -1,58 +1,35 @@ 'use client'; -import { useState } from 'react'; import { LoadingButton } from '@/components/ui/loading-button'; -import { useToast } from '@/components/hooks/use-toast'; -import { isServiceError } from '@/lib/utils'; -import { connectMcpToAsk } from '@/app/api/(client)/client'; -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, PlusIcon } from 'lucide-react'; +import type { ButtonProps } from '@/components/ui/button'; +import { useConnectMcp } from '@/ee/features/mcp/hooks/useConnectMcp'; interface ConnectMcpButtonProps { serverId: string; isConnected?: boolean; isAuthExpired?: boolean; + size?: ButtonProps['size']; } -export function ConnectMcpButton({ serverId, isConnected, isAuthExpired }: ConnectMcpButtonProps) { - const [loading, setLoading] = useState(false); - const { toast } = useToast(); +export function ConnectMcpButton({ serverId, isConnected, isAuthExpired, size }: ConnectMcpButtonProps) { + const { connect, loadingServerId } = useConnectMcp(); + const loading = loadingServerId === serverId; - const buttonLabel = isConnected || isAuthExpired ? "Reconnect" : "Connect MCP Server"; + const isSuggested = !isConnected && !isAuthExpired; + const buttonLabel = isSuggested ? "Connect" : "Reconnect"; const buttonVariant = isConnected ? "outline" as const : undefined; - const handleConnect = async () => { - setLoading(true); - const result = await connectMcpToAsk({ serverId }); - - if (isServiceError(result)) { - toast({ - description: `Failed to connect MCP server. ${result.message}`, - }); - setLoading(false); - return; - } - - if (result.authorizationUrl) { - // OAuth flow — redirect to the authorization URL - window.location.href = result.authorizationUrl; - // Keep loading=true while redirecting (same pattern as ManageSubscriptionButton) - } else { - // Already authorized - toast({ - description: 'MCP server is already connected.', - }); - setLoading(false); - } - }; - return ( connect(serverId)} loading={loading} variant={buttonVariant} + size={size} > + {isSuggested && } {buttonLabel} - + {!isSuggested && } ); -} \ No newline at end of file +} diff --git a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts new file mode 100644 index 000000000..184a0d047 --- /dev/null +++ b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; +import { useToast } from '@/components/hooks/use-toast'; +import { useQueryClient } from '@tanstack/react-query'; +import { connectMcpToAsk } from '@/app/api/(client)/client'; +import { invalidateMcpConfigurationQueries } from '@/ee/features/mcp/queryKeys'; +import { isServiceError } from '@/lib/utils'; + +export function useConnectMcp() { + const [loadingServerId, setLoadingServerId] = useState(null); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const connect = async (serverId: string) => { + setLoadingServerId(serverId); + const result = await connectMcpToAsk({ serverId }); + + if (isServiceError(result)) { + toast({ + description: `Failed to connect MCP server. ${result.message}`, + }); + setLoadingServerId(null); + return; + } + + if (result.authorizationUrl) { + window.location.href = result.authorizationUrl; + } else { + toast({ + description: 'MCP server is already connected.', + }); + await invalidateMcpConfigurationQueries(queryClient); + setLoadingServerId(null); + } + }; + + return { connect, loadingServerId }; +} From 78cd0f05514d270b11bc59191a5ac71ddc3373e6 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Tue, 26 May 2026 13:41:12 -0700 Subject: [PATCH 16/40] Rename MCP settings to Ask Agent connectors --- .../components/settingsSidebar/nav.tsx | 7 +- .../chat/components/mcpOAuthStatusToast.tsx | 2 +- .../accountAskAgentPage.test.tsx | 33 ++ .../accountAskAgentPage.tsx} | 147 +++--- .../{mcpServers => accountAskAgent}/page.tsx | 6 +- .../web/src/app/(app)/settings/layout.tsx | 16 +- .../mcpConfiguration/mcpConfigurationPage.tsx | 441 ----------------- .../mcpServers/mcpServersPage.test.tsx | 25 - .../page.test.tsx | 14 +- .../page.tsx | 8 +- .../prefabConnectorPopover.tsx} | 12 +- .../workspaceAskAgentPage.tsx | 450 ++++++++++++++++++ .../workspaceAskAgentUnavailableMessage.tsx} | 12 +- .../(server)/ee/askmcp/callback/route.test.ts | 4 +- .../api/(server)/ee/askmcp/callback/route.ts | 6 +- .../(server)/ee/askmcp/connect/route.test.ts | 4 +- .../api/(server)/ee/askmcp/connect/route.ts | 8 +- .../features/analytics/analyticsContent.tsx | 2 +- .../web/src/ee/features/mcp/actions.test.ts | 8 +- packages/web/src/ee/features/mcp/actions.ts | 18 +- .../ee/features/mcp/hooks/useConnectMcp.ts | 4 +- .../components/chatBox/chatBoxPlusButton.tsx | 22 +- .../components/chatBox/plusButtonInfoCard.tsx | 4 +- .../chatThread/mcpFailedServersBanner.tsx | 6 +- .../tools/toolSearchToolComponent.tsx | 2 +- .../src/features/chat/mcpOAuthDraft.test.ts | 2 +- .../src/features/mcp/mcpOAuthReturnTo.test.ts | 4 + .../web/src/features/mcp/mcpOAuthReturnTo.ts | 2 +- 28 files changed, 653 insertions(+), 616 deletions(-) create mode 100644 packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx rename packages/web/src/app/(app)/settings/{mcpServers/mcpServersPage.tsx => accountAskAgent/accountAskAgentPage.tsx} (77%) rename packages/web/src/app/(app)/settings/{mcpServers => accountAskAgent}/page.tsx (79%) delete mode 100644 packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx delete mode 100644 packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx rename packages/web/src/app/(app)/settings/{mcpConfiguration => workspaceAskAgent}/page.test.tsx (77%) rename packages/web/src/app/(app)/settings/{mcpConfiguration => workspaceAskAgent}/page.tsx (65%) rename packages/web/src/app/(app)/settings/{mcpConfiguration/prefabMcpServerPopover.tsx => workspaceAskAgent/prefabConnectorPopover.tsx} (94%) create mode 100644 packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx rename packages/web/src/app/(app)/settings/{mcpConfiguration/mcpConfigurationUnavailableMessage.tsx => workspaceAskAgent/workspaceAskAgentUnavailableMessage.tsx} (70%) diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 306c5702e..7920b992c 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -12,6 +12,7 @@ import { import { useEntitlements } from "@/features/entitlements/useEntitlements"; import { Entitlement } from "@sourcebot/shared"; import { + BotIcon, ChartAreaIcon, KeyRoundIcon, LinkIcon, @@ -24,11 +25,9 @@ import { UserIcon, UsersIcon, } from "lucide-react"; -import { VscMcp } from "react-icons/vsc"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { UpgradeBadge } from "../upgradeBadge"; -import { IconType } from "react-icons/lib"; const iconMap = { "link": LinkIcon, @@ -41,8 +40,8 @@ const iconMap = { "server": ServerIcon, "settings": Settings2Icon, "user": UserIcon, - "mcp": VscMcp, -} satisfies Record; + "bot": BotIcon, +} satisfies Record; export type NavIconName = keyof typeof iconMap; diff --git a/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx b/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx index 0591a42c1..05fc2b929 100644 --- a/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx +++ b/packages/web/src/app/(app)/chat/components/mcpOAuthStatusToast.tsx @@ -30,7 +30,7 @@ export function McpOAuthStatusToast() { } else { toast({ title: "Connection failed", - description: message ?? 'Failed to connect MCP server.', + description: message ?? 'Failed to connect connector.', variant: "destructive", }); } diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx new file mode 100644 index 000000000..a56104170 --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +vi.mock('@/app/api/(client)/client', () => ({ + getMcpServersWithStatus: vi.fn(), +})); +vi.mock('@/ee/features/mcp/actions', () => ({ + disconnectMcpServer: vi.fn(), +})); + +const { AccountAskAgentEmptyState } = await import('./accountAskAgentPage'); + +afterEach(() => { + cleanup(); +}); + +describe('AccountAskAgentEmptyState', () => { + test('points owners to workspace Ask Agent settings', () => { + render(); + + expect(screen.getByText('No connectors configured yet')).toBeTruthy(); + expect(screen.getByText('Open Workspace Ask Agent to approve connectors for your workspace.')).toBeTruthy(); + expect(screen.getByRole('link', { name: /Open Workspace Ask Agent/ }).getAttribute('href')).toBe('/settings/workspaceAskAgent'); + }); + + test('tells members to contact an admin', () => { + render(); + + expect(screen.getByText('No connectors available')).toBeTruthy(); + expect(screen.getByText(/Contact your workspace admin/)).toBeTruthy(); + expect(screen.queryByRole('link', { name: /Open Workspace Ask Agent/ })).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx similarity index 77% rename from packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx rename to packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index d06239bfb..a5633a578 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ExternalLink, MoreHorizontal, SearchIcon, ServerIcon, Settings2Icon, Unplug } from "lucide-react"; +import { CableIcon, ExternalLink, MoreHorizontal, SearchIcon, Settings2Icon, Unplug } from "lucide-react"; import { getMcpServersWithStatus } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; import { @@ -42,33 +42,33 @@ function clearCallbackParams() { window.history.replaceState({}, '', url.toString()); } -interface McpServersPageProps { +interface AccountAskAgentPageProps { callbackStatus?: string; callbackServer?: string; callbackMessage?: string; - canManageMcpServers: boolean; + canManageConnectors: boolean; } -export function McpServersEmptyState({ canManageMcpServers }: { canManageMcpServers: boolean }) { +export function AccountAskAgentEmptyState({ canManageConnectors }: { canManageConnectors: boolean }) { return (
- +

- {canManageMcpServers ? "No MCP servers configured yet" : "No MCP servers available"} + {canManageConnectors ? "No connectors configured yet" : "No connectors available"}

- {canManageMcpServers - ? "Go to Workspace MCP Configuration to add servers before connecting them to Ask Sourcebot." - : "No MCP servers have been approved for this workspace yet. Contact your workspace admin."} + {canManageConnectors + ? "Open Workspace Ask Agent to approve connectors for your workspace." + : "No connectors have been approved for this workspace yet. Contact your workspace admin."}

- {canManageMcpServers && ( + {canManageConnectors && ( )} @@ -77,7 +77,7 @@ export function McpServersEmptyState({ canManageMcpServers }: { canManageMcpServ ); } -export function McpServersPage({ callbackStatus, callbackServer, callbackMessage, canManageMcpServers }: McpServersPageProps) { +export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMessage, canManageConnectors }: AccountAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); const didHandleCallbackRef = useRef(false); @@ -97,7 +97,7 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage clearCallbackParams(); } else if (callbackStatus === 'error') { didHandleCallbackRef.current = true; - toast({ title: "Connection failed", description: callbackMessage ?? 'Failed to connect MCP server.', variant: "destructive" }); + toast({ title: "Connection failed", description: callbackMessage ?? 'Failed to connect connector.', variant: "destructive" }); clearCallbackParams(); } }, [callbackStatus, callbackServer, callbackMessage, toast]); @@ -107,7 +107,7 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage queryFn: async () => { const result = await getMcpServersWithStatus(); if (isServiceError(result)) { - throw new Error("Failed to load MCP servers"); + throw new Error("Failed to load connectors"); } return result; }, @@ -157,29 +157,37 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage toast({ title: "Error", description: `Failed to disconnect: ${result.message}`, variant: "destructive" }); return; } - toast({ description: "MCP server disconnected." }); + toast({ description: "Connector disconnected." }); await invalidateMcpConfigurationQueries(queryClient); } catch { - toast({ title: "Error", description: "Failed to disconnect MCP server.", variant: "destructive" }); + toast({ title: "Error", description: "Failed to disconnect connector.", variant: "destructive" }); } finally { setDisconnectingServerId(null); } }; if (isError) { - return
Error loading MCP servers
; + return
Error loading connectors
; } if (!isLoading && servers.length === 0) { return (
-

MCP Servers

+

Ask Agent

- Connect to workspace-approved MCP servers to use them with Ask Sourcebot. + Manage your personal Ask Agent setup.

- +
+
+

Connectors

+

+ Manage workspace-approved connectors for use with Ask Agent. +

+
+ +
); } @@ -187,48 +195,57 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage return (
-

MCP Servers

+

Ask Agent

- Connect to workspace-approved MCP servers to use them with Ask Sourcebot. + Manage your personal Ask Agent setup.

- {/* Search + filter bar */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> +
+
+

Connectors

+

+ Manage workspace-approved connectors for use with Ask Agent. +

-
- - + + {/* Search + filter bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + +
@@ -256,7 +273,7 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage Connected

- {connectedServers.length} {pluralize(connectedServers.length, "server")} + {connectedServers.length} {pluralize(connectedServers.length, "connector")}

@@ -265,8 +282,8 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage

{searchQuery.trim() - ? "No connected servers match your search." - : "No servers connected yet."} + ? "No connected connectors match your search." + : "No connectors connected yet."}

@@ -353,8 +370,8 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage

{searchQuery.trim() - ? "No suggested servers match your search." - : "All servers are connected."} + ? "No suggested connectors match your search." + : "All connectors are connected."}

@@ -399,9 +416,9 @@ export function McpServersPage({ callbackStatus, callbackServer, callbackMessage > - Disconnect MCP Server + Disconnect Connector - Are you sure you want to disconnect from {confirmDisconnectServer?.name}? Your stored credentials for this server will be removed. + Are you sure you want to disconnect from {confirmDisconnectServer?.name}? Your stored credentials for this connector will be removed. diff --git a/packages/web/src/app/(app)/settings/mcpServers/page.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx similarity index 79% rename from packages/web/src/app/(app)/settings/mcpServers/page.tsx rename to packages/web/src/app/(app)/settings/accountAskAgent/page.tsx index 7c6c43ad1..9db22ac70 100644 --- a/packages/web/src/app/(app)/settings/mcpServers/page.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx @@ -1,4 +1,4 @@ -import { McpServersPage } from "./mcpServersPage"; +import { AccountAskAgentPage } from "./accountAskAgentPage"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; @@ -13,11 +13,11 @@ interface PageProps extends Record { export default authenticatedPage(async ({ role }, { searchParams }) => { const { status, server, message } = await searchParams; return ( - ); }); diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 6a65ab415..515e4fb51 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -59,7 +59,7 @@ export const getSidebarNavGroups = async () => throw new ServiceErrorException(connectionStats); } const hasOAuthEntitlement = await hasEntitlement("oauth"); - const hasApprovedMcpServers = role === OrgRole.OWNER && !hasOAuthEntitlement + const hasApprovedConnectors = role === OrgRole.OWNER && !hasOAuthEntitlement ? await prisma.mcpServer.count({ where: { orgId: org.id }, }) > 0 @@ -90,9 +90,9 @@ export const getSidebarNavGroups = async () => ] : []), ...(hasOAuthEntitlement ? [ { - title: "MCP Servers", - href: `/settings/mcpServers`, - icon: "mcp" as const, + title: "Ask Agent", + href: `/settings/accountAskAgent`, + icon: "bot" as const, } ] : []), ], @@ -127,11 +127,11 @@ export const getSidebarNavGroups = async () => icon: "chart-area" as const, requiredEntitlement: 'analytics' }, - ...(hasOAuthEntitlement || hasApprovedMcpServers ? [ + ...(hasOAuthEntitlement || hasApprovedConnectors ? [ { - title: "MCP Configuration", - href: `/settings/mcpConfiguration`, - icon: "server" as const, + title: "Ask Agent", + href: `/settings/workspaceAskAgent`, + icon: "bot" as const, } ] : []), { diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx deleted file mode 100644 index dfd50c929..000000000 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationPage.tsx +++ /dev/null @@ -1,441 +0,0 @@ -'use client'; - -import { useState } from "react"; -import { getMcpConfiguration } from "@/app/api/(client)/client"; -import { useToast } from "@/components/hooks/use-toast"; -import { - AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; -import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { isServiceError } from "@/lib/utils"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangleIcon, Loader2, MinusIcon, PlusIcon, ServerIcon } from "lucide-react"; -import { PrefabMcpServerPopover } from "./prefabMcpServerPopover"; -import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; - -function pluralize(count: number, singular: string, plural = `${singular}s`) { - return count === 1 ? singular : plural; -} - -export function McpConfigurationPage() { - const { toast } = useToast(); - const queryClient = useQueryClient(); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [newServerName, setNewServerName] = useState(""); - const [newServerUrl, setNewServerUrl] = useState(""); - const [isClientCredentialsDialogOpen, setIsClientCredentialsDialogOpen] = useState(false); - const [pendingClientCredentialsServer, setPendingClientCredentialsServer] = useState<{ name: string; serverUrl: string } | null>(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [isCreating, setIsCreating] = useState(false); - const [deletingServerId, setDeletingServerId] = useState(null); - - const { data, isLoading, isError } = useQuery({ - queryKey: mcpQueryKeys.configuration, - queryFn: async () => { - const result = await getMcpConfiguration(); - if (isServiceError(result)) { - throw new Error(result.message); - } - return result; - }, - }); - - const servers = data?.servers ?? []; - const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; - const canCreateMcpServers = data?.isOAuthAvailable === true; - const isOAuthUnavailable = data?.isOAuthAvailable === false; - - const handleCreateDialogOpenChange = (open: boolean) => { - setIsCreateDialogOpen(open); - - if (!open) { - setNewServerName(""); - setNewServerUrl(""); - } - }; - - const handleCloseCreateDialog = () => { - handleCreateDialogOpenChange(false); - }; - - const handleCloseClientCredentialsDialog = () => { - setIsClientCredentialsDialogOpen(false); - setPendingClientCredentialsServer(null); - setClientId(""); - setClientSecret(""); - }; - - const handleOpenCustomUrlDialog = () => { - setNewServerName(""); - setNewServerUrl(""); - setIsCreateDialogOpen(true); - }; - - const handleCreateStaticOAuthServer = async () => { - if (!pendingClientCredentialsServer) { - toast({ title: "Error", description: "Missing MCP server details", variant: "destructive" }); - return; - } - - if (process.env.NODE_ENV === "production" && window.location.protocol !== "https:") { - toast({ - title: "HTTPS required", - description: "Static OAuth client credentials can only be submitted over HTTPS in production.", - variant: "destructive", - }); - return; - } - - setIsCreating(true); - try { - const result = await createStaticOAuthMcpServer({ - name: pendingClientCredentialsServer.name, - serverUrl: pendingClientCredentialsServer.serverUrl, - clientId, - clientSecret, - }); - if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); - return; - } - - await invalidateMcpConfigurationQueries(queryClient); - handleCloseClientCredentialsDialog(); - } catch { - toast({ title: "Error", description: "Failed to add MCP server.", variant: "destructive" }); - } finally { - setIsCreating(false); - } - }; - - const handleCreateServer = async ( - name: string, - serverUrl: string, - onSuccess?: () => void, - options: { checkDynamicClientRegistration?: boolean } = {}, - ) => { - const displayName = name.trim(); - const normalizedServerUrl = serverUrl.trim(); - - if (!displayName || !normalizedServerUrl) { - toast({ title: "Error", description: "Name and server URL are required", variant: "destructive" }); - return; - } - - setIsCreating(true); - try { - if (options.checkDynamicClientRegistration) { - const dcrSupport = await checkMcpServerDynamicClientRegistration(normalizedServerUrl); - if (isServiceError(dcrSupport)) { - toast({ title: "Error", description: `Failed to check MCP server: ${dcrSupport.message}`, variant: "destructive" }); - return; - } - - if (dcrSupport.isKnown && !dcrSupport.supportsDcr) { - setPendingClientCredentialsServer({ name: displayName, serverUrl: normalizedServerUrl }); - setIsCreateDialogOpen(false); - setIsClientCredentialsDialogOpen(true); - return; - } - } - - const result = await createMcpServer(displayName, normalizedServerUrl); - if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to add MCP server: ${result.message}`, variant: "destructive" }); - return; - } - - await invalidateMcpConfigurationQueries(queryClient); - onSuccess?.(); - } catch (error) { - toast({ title: "Error", description: `Failed to add MCP server: ${error}`, variant: "destructive" }); - } finally { - setIsCreating(false); - } - }; - - const handleCreate = async () => { - await handleCreateServer(newServerName, newServerUrl, handleCloseCreateDialog, { - checkDynamicClientRegistration: true, - }); - }; - - const handleCreatePrefabServer = async (server: PrefabMcpServer) => { - await handleCreateServer(server.name, server.serverUrl, undefined, { - checkDynamicClientRegistration: true, - }); - }; - - const handleDelete = async (serverId: string) => { - setDeletingServerId(serverId); - try { - const result = await deleteMcpServer(serverId); - if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to delete MCP server: ${result.message}`, variant: "destructive" }); - return; - } - - await invalidateMcpConfigurationQueries(queryClient); - } catch (error) { - toast({ title: "Error", description: `Failed to delete MCP server: ${error}`, variant: "destructive" }); - } finally { - setDeletingServerId(null); - } - }; - - if (isError) { - return
Error loading MCP configuration
; - } - - return ( -
-
-

MCP Configuration

-

- Configure the MCP servers that workspace members can connect to. -

-
- - {!isLoading && isOAuthUnavailable && ( - - - -
-

OAuth MCP is unavailable

-

- You can remove existing approved servers and stored credentials, but cannot add new MCP servers. -

-
-
-
- )} - - - -
-
-

Saved MCP connections

-

- Current workspace members with saved MCP server credentials. -

-
- {isLoading ? ( - - ) : ( -

- {totalSavedConnectionCount} {pluralize(totalSavedConnectionCount, "connection")} -

- )} -
-
-
- - - -
- {isLoading ? "Allowed servers" : `${servers.length} allowed ${pluralize(servers.length, "server")}`} - - {isOAuthUnavailable - ? "Remove existing server approvals and their stored credentials." - : "Approve server URLs that workspace members can connect to."} - -
- {canCreateMcpServers ? ( - <> - server.serverUrl)} - disabled={isCreating} - onSelectCustomUrl={handleOpenCustomUrlDialog} - onSelectPrefabServer={handleCreatePrefabServer} - /> - - - - Add MCP Server - - Add a workspace-approved MCP server that members can connect to from Ask Sourcebot. - - -
-
- - setNewServerName(event.target.value)} - placeholder="e.g. Linear" - /> -
-
- - setNewServerUrl(event.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> -
-
- - - - -
-
- { - if (!open) { - handleCloseClientCredentialsDialog(); - return; - } - - setIsClientCredentialsDialogOpen(true); - }}> - - - OAuth Client Credentials Required - - This MCP server does not advertise dynamic client registration. Provide OAuth client credentials from a pre-registered app before members can connect to it. - - -
- {pendingClientCredentialsServer && ( -
-

{pendingClientCredentialsServer.name}

-

{pendingClientCredentialsServer.serverUrl}

-
- )} -
- - setClientId(event.target.value)} - placeholder="OAuth client ID" - /> -
-
- - setClientSecret(event.target.value)} - placeholder="OAuth client secret" - /> -
-
- - - - -
-
- - ) : ( - - )} -
- - {isLoading ? ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- -
- - -
- -
- ))} -
- ) : servers.length === 0 ? ( -
-
- -
-

No MCP servers configured yet

-

- {isOAuthUnavailable - ? "OAuth MCP is unavailable on this Sourcebot instance." - : "Add a workspace-approved MCP server so members can connect it to Ask Sourcebot."} -

-
- ) : ( -
- {servers.map((server) => ( -
-
- -
-
-

{server.name || server.serverUrl}

-

{server.serverUrl}

-
-

- {server.savedConnectionCount} {pluralize(server.savedConnectionCount, "saved connection")} -

- - - - - - - Delete MCP Server - - Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this server. - - - - Cancel - handleDelete(server.id)} - disabled={deletingServerId === server.id} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deletingServerId === server.id ? "Deleting..." : "Delete"} - - - - -
- ))} -
- )} -
-
-
- ); -} diff --git a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx b/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx deleted file mode 100644 index 6f9221d80..000000000 --- a/packages/web/src/app/(app)/settings/mcpServers/mcpServersPage.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { afterEach, describe, expect, test } from 'vitest'; -import { cleanup, render, screen } from '@testing-library/react'; -import { McpServersEmptyState } from './mcpServersPage'; - -afterEach(() => { - cleanup(); -}); - -describe('McpServersEmptyState', () => { - test('points owners to workspace MCP configuration', () => { - render(); - - expect(screen.getByText('No MCP servers configured yet')).toBeTruthy(); - expect(screen.getByText(/Go to Workspace MCP Configuration/)).toBeTruthy(); - expect(screen.getByRole('link', { name: /Open MCP Configuration/ }).getAttribute('href')).toBe('/settings/mcpConfiguration'); - }); - - test('tells members to contact an admin', () => { - render(); - - expect(screen.getByText('No MCP servers available')).toBeTruthy(); - expect(screen.getByText(/Contact your workspace admin/)).toBeTruthy(); - expect(screen.queryByRole('link', { name: /Open MCP Configuration/ })).toBeNull(); - }); -}); diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx similarity index 77% rename from packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx rename to packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx index f349a072a..9e88df1c7 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/page.test.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx @@ -20,8 +20,8 @@ vi.mock('@/lib/entitlements', () => ({ vi.mock('@/middleware/authenticatedPage', () => ({ authenticatedPage: vi.fn((page: (auth: typeof mocks.authContext) => Promise) => () => page(mocks.authContext)), })); -vi.mock('./mcpConfigurationPage', () => ({ - McpConfigurationPage: () =>
MCP configuration client
, +vi.mock('./workspaceAskAgentPage', () => ({ + WorkspaceAskAgentPage: () =>
Workspace Ask Agent client
, })); const { default: Page } = await import('./page'); @@ -36,11 +36,11 @@ afterEach(() => { cleanup(); }); -describe('MCP configuration settings page', () => { +describe('Ask Agent settings page', () => { test('renders the client configuration page when OAuth is available', async () => { render(await Page({})); - expect(screen.getByText('MCP configuration client')).toBeTruthy(); + expect(screen.getByText('Workspace Ask Agent client')).toBeTruthy(); }); test('renders the client configuration page when OAuth is unavailable but servers exist for cleanup', async () => { @@ -49,7 +49,7 @@ describe('MCP configuration settings page', () => { render(await Page({})); - expect(screen.getByText('MCP configuration client')).toBeTruthy(); + expect(screen.getByText('Workspace Ask Agent client')).toBeTruthy(); expect(mocks.authContext.prisma.mcpServer.count).toHaveBeenCalledWith({ where: { orgId: 1 }, }); @@ -60,7 +60,7 @@ describe('MCP configuration settings page', () => { render(await Page({})); - expect(screen.getByText('MCP Configuration Is Unavailable')).toBeTruthy(); - expect(screen.queryByText('MCP configuration client')).toBeNull(); + expect(screen.getByText('Ask Agent Connectors Are Unavailable')).toBeTruthy(); + expect(screen.queryByText('Workspace Ask Agent client')).toBeNull(); }); }); diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx similarity index 65% rename from packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx rename to packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx index c6c1015f5..6dea782b4 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/page.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx @@ -1,8 +1,8 @@ import { hasEntitlement } from "@/lib/entitlements"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; -import { McpConfigurationPage } from "./mcpConfigurationPage"; -import { McpConfigurationUnavailableMessage } from "./mcpConfigurationUnavailableMessage"; +import { WorkspaceAskAgentPage } from "./workspaceAskAgentPage"; +import { WorkspaceAskAgentUnavailableMessage } from "./workspaceAskAgentUnavailableMessage"; export default authenticatedPage(async ({ org, prisma }) => { if (!(await hasEntitlement("oauth"))) { @@ -11,9 +11,9 @@ export default authenticatedPage(async ({ org, prisma }) => { }); if (serverCount === 0) { - return ; + return ; } } - return ; + return ; }, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx similarity index 94% rename from packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx rename to packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx index f09ba07c9..a94dff0fb 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx @@ -19,7 +19,7 @@ import { import { getMcpFaviconUrl } from "@/ee/features/mcp/utils"; import { PlusIcon } from "lucide-react"; -interface PrefabMcpServerPopoverProps { +interface PrefabConnectorPopoverProps { configuredServerUrls: string[]; disabled?: boolean; onSelectCustomUrl: () => void; @@ -35,12 +35,12 @@ function getDisplayServerUrl(serverUrl: string) { } } -export function PrefabMcpServerPopover({ +export function PrefabConnectorPopover({ configuredServerUrls, disabled, onSelectCustomUrl, onSelectPrefabServer, -}: PrefabMcpServerPopoverProps) { +}: PrefabConnectorPopoverProps) { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); @@ -79,14 +79,14 @@ export function PrefabMcpServerPopover({ return ( - @@ -110,7 +110,7 @@ export function PrefabMcpServerPopover({ ))} {search.trim() && filteredPrefabServers.length === 0 && (
- No servers found. + No connectors found.
)} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx new file mode 100644 index 000000000..ef35875bb --- /dev/null +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -0,0 +1,450 @@ +'use client'; + +import { useState } from "react"; +import { getMcpConfiguration } from "@/app/api/(client)/client"; +import { useToast } from "@/components/hooks/use-toast"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { isServiceError } from "@/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangleIcon, CableIcon, Loader2, MinusIcon, PlusIcon } from "lucide-react"; +import { PrefabConnectorPopover } from "./prefabConnectorPopover"; +import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +export function WorkspaceAskAgentPage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newServerName, setNewServerName] = useState(""); + const [newServerUrl, setNewServerUrl] = useState(""); + const [isClientCredentialsDialogOpen, setIsClientCredentialsDialogOpen] = useState(false); + const [pendingClientCredentialsServer, setPendingClientCredentialsServer] = useState<{ name: string; serverUrl: string } | null>(null); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [deletingServerId, setDeletingServerId] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: mcpQueryKeys.configuration, + queryFn: async () => { + const result = await getMcpConfiguration(); + if (isServiceError(result)) { + throw new Error(result.message); + } + return result; + }, + }); + + const servers = data?.servers ?? []; + const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; + const canCreateConnectors = data?.isOAuthAvailable === true; + const isOAuthUnavailable = data?.isOAuthAvailable === false; + + const handleCreateDialogOpenChange = (open: boolean) => { + setIsCreateDialogOpen(open); + + if (!open) { + setNewServerName(""); + setNewServerUrl(""); + } + }; + + const handleCloseCreateDialog = () => { + handleCreateDialogOpenChange(false); + }; + + const handleCloseClientCredentialsDialog = () => { + setIsClientCredentialsDialogOpen(false); + setPendingClientCredentialsServer(null); + setClientId(""); + setClientSecret(""); + }; + + const handleOpenCustomUrlDialog = () => { + setNewServerName(""); + setNewServerUrl(""); + setIsCreateDialogOpen(true); + }; + + const handleCreateStaticOAuthServer = async () => { + if (!pendingClientCredentialsServer) { + toast({ title: "Error", description: "Missing connector details", variant: "destructive" }); + return; + } + + if (process.env.NODE_ENV === "production" && window.location.protocol !== "https:") { + toast({ + title: "HTTPS required", + description: "Static OAuth client credentials can only be submitted over HTTPS in production.", + variant: "destructive", + }); + return; + } + + setIsCreating(true); + try { + const result = await createStaticOAuthMcpServer({ + name: pendingClientCredentialsServer.name, + serverUrl: pendingClientCredentialsServer.serverUrl, + clientId, + clientSecret, + }); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to add connector: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + handleCloseClientCredentialsDialog(); + } catch { + toast({ title: "Error", description: "Failed to add connector.", variant: "destructive" }); + } finally { + setIsCreating(false); + } + }; + + const handleCreateServer = async ( + name: string, + serverUrl: string, + onSuccess?: () => void, + options: { checkDynamicClientRegistration?: boolean } = {}, + ) => { + const displayName = name.trim(); + const normalizedServerUrl = serverUrl.trim(); + + if (!displayName || !normalizedServerUrl) { + toast({ title: "Error", description: "Name and connector URL are required", variant: "destructive" }); + return; + } + + setIsCreating(true); + try { + if (options.checkDynamicClientRegistration) { + const dcrSupport = await checkMcpServerDynamicClientRegistration(normalizedServerUrl); + if (isServiceError(dcrSupport)) { + toast({ title: "Error", description: `Failed to check connector: ${dcrSupport.message}`, variant: "destructive" }); + return; + } + + if (dcrSupport.isKnown && !dcrSupport.supportsDcr) { + setPendingClientCredentialsServer({ name: displayName, serverUrl: normalizedServerUrl }); + setIsCreateDialogOpen(false); + setIsClientCredentialsDialogOpen(true); + return; + } + } + + const result = await createMcpServer(displayName, normalizedServerUrl); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to add connector: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + onSuccess?.(); + } catch (error) { + toast({ title: "Error", description: `Failed to add connector: ${error}`, variant: "destructive" }); + } finally { + setIsCreating(false); + } + }; + + const handleCreate = async () => { + await handleCreateServer(newServerName, newServerUrl, handleCloseCreateDialog, { + checkDynamicClientRegistration: true, + }); + }; + + const handleCreatePrefabServer = async (server: PrefabMcpServer) => { + await handleCreateServer(server.name, server.serverUrl, undefined, { + checkDynamicClientRegistration: true, + }); + }; + + const handleDelete = async (serverId: string) => { + setDeletingServerId(serverId); + try { + const result = await deleteMcpServer(serverId); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to delete connector: ${result.message}`, variant: "destructive" }); + return; + } + + await invalidateMcpConfigurationQueries(queryClient); + } catch (error) { + toast({ title: "Error", description: `Failed to delete connector: ${error}`, variant: "destructive" }); + } finally { + setDeletingServerId(null); + } + }; + + if (isError) { + return
Error loading Ask Agent settings
; + } + + return ( +
+
+

Ask Agent

+

+ Customize Ask Agent for your workspace. +

+
+ + {!isLoading && isOAuthUnavailable && ( + + + +
+

Connector OAuth is unavailable

+

+ You can remove existing approved connectors and stored credentials, but cannot add new connectors. +

+
+
+
+ )} + +
+
+

Connectors

+

+ Connectors are MCP servers that let Ask Agent use approved external tools. +

+
+ + + +
+
+

Saved connector connections

+

+ Current workspace members with saved connector credentials. +

+
+ {isLoading ? ( + + ) : ( +

+ {totalSavedConnectionCount} {pluralize(totalSavedConnectionCount, "connection")} +

+ )} +
+
+
+ + + +
+ {isLoading ? "Allowed connectors" : `${servers.length} allowed ${pluralize(servers.length, "connector")}`} + + {isOAuthUnavailable + ? "Remove existing connector approvals and their stored credentials." + : "Approve connector URLs that workspace members can connect to."} + +
+ {canCreateConnectors ? ( + <> + server.serverUrl)} + disabled={isCreating} + onSelectCustomUrl={handleOpenCustomUrlDialog} + onSelectPrefabServer={handleCreatePrefabServer} + /> + + + + Add Connector + + Add a workspace-approved connector that members can use with Ask Agent. + + +
+
+ + setNewServerName(event.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(event.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+
+ { + if (!open) { + handleCloseClientCredentialsDialog(); + return; + } + + setIsClientCredentialsDialogOpen(true); + }}> + + + OAuth Client Credentials Required + + This connector does not advertise dynamic client registration. Provide OAuth client credentials from a pre-registered app before members can connect to it. + + +
+ {pendingClientCredentialsServer && ( +
+

{pendingClientCredentialsServer.name}

+

{pendingClientCredentialsServer.serverUrl}

+
+ )} +
+ + setClientId(event.target.value)} + placeholder="OAuth client ID" + /> +
+
+ + setClientSecret(event.target.value)} + placeholder="OAuth client secret" + /> +
+
+ + + + +
+
+ + ) : ( + + )} +
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : servers.length === 0 ? ( +
+
+ +
+

No connectors configured yet

+

+ {isOAuthUnavailable + ? "Connector OAuth is unavailable on this Sourcebot instance." + : "Add a workspace-approved connector so members can use it with Ask Agent."} +

+
+ ) : ( +
+ {servers.map((server) => ( +
+
+ +
+
+

{server.name || server.serverUrl}

+

{server.serverUrl}

+
+

+ {server.savedConnectionCount} {pluralize(server.savedConnectionCount, "saved connection")} +

+ + + + + + + Delete Connector + + Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this connector. + + + + Cancel + handleDelete(server.id)} + disabled={deletingServerId === server.id} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deletingServerId === server.id ? "Deleting..." : "Delete"} + + + + +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentUnavailableMessage.tsx similarity index 70% rename from packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx rename to packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentUnavailableMessage.tsx index 6ef7ded41..21ac97209 100644 --- a/packages/web/src/app/(app)/settings/mcpConfiguration/mcpConfigurationUnavailableMessage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentUnavailableMessage.tsx @@ -1,26 +1,26 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { ServerIcon } from "lucide-react"; +import { CableIcon } from "lucide-react"; -export function McpConfigurationUnavailableMessage() { +export function WorkspaceAskAgentUnavailableMessage() { return (
- +
- MCP Configuration Is Unavailable + Ask Agent Connectors Are Unavailable - OAuth-backed MCP servers are not supported on this Sourcebot instance. + OAuth-backed connectors are not supported on this Sourcebot instance.

- Use Sourcebot API keys for MCP access on this deployment. + Use Sourcebot API keys for agent access on this deployment.

diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index 31ce476fd..039cb8c14 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -143,9 +143,9 @@ describe('GET /api/ee/askmcp/callback', () => { const location = response.headers.get('location'); expect(location).toBeTruthy(); - expect(location).toContain('/settings/mcpServers'); + expect(location).toContain('/settings/accountAskAgent'); expect(location).toContain('status=error'); - expect(new URL(location ?? '').searchParams.get('message')).toContain('Please reconnect the server'); + expect(new URL(location ?? '').searchParams.get('message')).toContain('Please reconnect the connector'); expect(mocks.unsafePrisma.userMcpServer.findFirst).toHaveBeenCalledWith({ where: { state: 'state-1', diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index 30906ba32..287064381 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -14,8 +14,8 @@ import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError import { getMcpOAuthReturnToFromState } from '@/features/mcp/mcpOAuthReturnTo'; const logger = createLogger('mcp-oauth-callback'); -const reconnectMessage = 'This MCP server authorization could not be completed. Please reconnect the server.'; -const defaultMcpOAuthReturnTo = '/settings/mcpServers'; +const reconnectMessage = 'This connector authorization could not be completed. Please reconnect the connector.'; +const defaultMcpOAuthReturnTo = '/settings/accountAskAgent'; function createMcpOAuthRedirectUrl(returnTo: string | undefined): URL { return new URL(returnTo ?? defaultMcpOAuthReturnTo, env.AUTH_URL); @@ -110,7 +110,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!orgMembership) { return Response.json( - { error: 'forbidden', error_description: 'You do not have access to this MCP server.' }, + { error: 'forbidden', error_description: 'You do not have access to this connector.' }, { status: 403 } ); } diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 6a379c6de..2c01c5e47 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -231,9 +231,9 @@ describe('POST /api/ee/askmcp/connect', () => { expect(response.status).toBe(502); expect(body).toMatchObject({ - message: 'Could not start MCP authorization.', + message: 'Could not start connector authorization.', }); - expect(mocks.logger.warn).toHaveBeenCalledWith('Failed to start MCP authorization.', { + expect(mocks.logger.warn).toHaveBeenCalledWith('Failed to start connector authorization.', { serverId: 'server-1', orgId: 1, error: { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 89f02381a..0e19e7396 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -65,7 +65,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); if (!mcpServer) { - return notFound('MCP server not found'); + return notFound('Connector not found'); } await prisma.userMcpServer.upsert({ @@ -91,7 +91,7 @@ export const POST = apiHandler(async (request: NextRequest) => { `; if (lockedRows.length === 0) { - throw new ServiceErrorException(notFound('MCP server not found')); + throw new ServiceErrorException(notFound('Connector not found')); } const provider = new PrismaOAuthClientProvider({ @@ -112,7 +112,7 @@ export const POST = apiHandler(async (request: NextRequest) => { fetchFn: createTimeoutFetch(MCP_AUTH_FETCH_TIMEOUT_MS), }); } catch (error) { - logger.warn('Failed to start MCP authorization.', { + logger.warn('Failed to start connector authorization.', { serverId: mcpServer.id, orgId: org.id, error: getExternalMcpErrorLogFields(error), @@ -120,7 +120,7 @@ export const POST = apiHandler(async (request: NextRequest) => { throw new ServiceErrorException({ statusCode: StatusCodes.BAD_GATEWAY, errorCode: ErrorCode.UNEXPECTED_ERROR, - message: 'Could not start MCP authorization.', + message: 'Could not start connector authorization.', }); } diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx index 91e62254a..ad2e5bffb 100644 --- a/packages/web/src/ee/features/analytics/analyticsContent.tsx +++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx @@ -2,7 +2,7 @@ import { ChartTooltip } from "@/components/ui/chart" import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from "recharts" -import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle, Wrench, Key, Info } from "lucide-react" +import { Users, LucideIcon, Activity, Calendar, Info } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ChartContainer } from "@/components/ui/chart" import { useQuery } from "@tanstack/react-query" diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts index a37a84e21..507dc28f7 100644 --- a/packages/web/src/ee/features/mcp/actions.test.ts +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -256,7 +256,7 @@ describe('createStaticOAuthMcpServer', () => { expect(result).toMatchObject({ statusCode: 400, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Invalid server URL. Must be a valid HTTPS URL.', + message: 'Invalid connector URL. Must be a valid HTTPS URL.', }); expect(prisma.mcpServer.findUnique).not.toHaveBeenCalled(); expect(prisma.mcpServer.create).not.toHaveBeenCalled(); @@ -274,7 +274,7 @@ describe('createStaticOAuthMcpServer', () => { expect(result).toMatchObject({ statusCode: 400, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Server name must contain at least 3 alphanumeric characters.', + message: 'Connector name must contain at least 3 alphanumeric characters.', }); expect(prisma.mcpServer.findUnique).not.toHaveBeenCalled(); expect(prisma.mcpServer.create).not.toHaveBeenCalled(); @@ -291,7 +291,7 @@ describe('createStaticOAuthMcpServer', () => { expect(result).toMatchObject({ statusCode: 409, errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: 'An MCP server with URL "https://mcp.slack.com/mcp" already exists.', + message: 'A connector with URL "https://mcp.slack.com/mcp" already exists.', }); expect(prisma.mcpServer.findFirst).not.toHaveBeenCalled(); expect(prisma.mcpServer.create).not.toHaveBeenCalled(); @@ -310,7 +310,7 @@ describe('createStaticOAuthMcpServer', () => { expect(result).toMatchObject({ statusCode: 409, errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: 'An MCP server with a similar name already exists. Please choose a more distinct name.', + message: 'A connector with a similar name already exists. Please choose a more distinct name.', }); expect(prisma.mcpServer.findUnique).toHaveBeenCalledWith({ where: { diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index ebe2470c6..fe765b8b4 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -120,13 +120,13 @@ async function prepareMcpServerCreate({ const urlResult = z.string().url().safeParse(normalizedServerUrl); const protocol = urlResult.success ? new URL(normalizedServerUrl).protocol : undefined; if (!urlResult.success || protocol !== 'https:') { - return invalidRequest('Invalid server URL. Must be a valid HTTPS URL.'); + return invalidRequest('Invalid connector URL. Must be a valid HTTPS URL.'); } const sanitizedName = sanitizeMcpServerName(displayName); const alphanumericCount = (sanitizedName.match(/[a-z0-9]/g) ?? []).length; if (alphanumericCount < 3) { - return invalidRequest('Server name must contain at least 3 alphanumeric characters.'); + return invalidRequest('Connector name must contain at least 3 alphanumeric characters.'); } const existingServer = await prisma.mcpServer.findUnique({ @@ -142,7 +142,7 @@ async function prepareMcpServerCreate({ return { statusCode: StatusCodes.CONFLICT, errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: `An MCP server with URL "${normalizedServerUrl}" already exists.`, + message: `A connector with URL "${normalizedServerUrl}" already exists.`, } satisfies ServiceError; } @@ -157,7 +157,7 @@ async function prepareMcpServerCreate({ return { statusCode: StatusCodes.CONFLICT, errorCode: ErrorCode.MCP_SERVER_ALREADY_EXISTS, - message: 'An MCP server with a similar name already exists. Please choose a more distinct name.', + message: 'A connector with a similar name already exists. Please choose a more distinct name.', } satisfies ServiceError; } @@ -182,7 +182,7 @@ export const checkMcpServerDynamicClientRegistration = async (serverUrl: string) return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Invalid server URL. Must be a valid HTTPS URL.', + message: 'Invalid connector URL. Must be a valid HTTPS URL.', } satisfies ServiceError; } @@ -195,7 +195,7 @@ export const checkMcpServerDynamicClientRegistration = async (serverUrl: string) return { statusCode: StatusCodes.BAD_GATEWAY, errorCode: ErrorCode.UNEXPECTED_ERROR, - message: 'Could not check whether this MCP server supports dynamic client registration.', + message: 'Could not check whether this connector supports dynamic client registration.', } satisfies ServiceError; } }))); @@ -312,7 +312,7 @@ export const deleteMcpServer = async (serverId: string) => sew(() => return { statusCode: StatusCodes.NOT_FOUND, errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, - message: 'MCP server not found', + message: 'Connector not found', } satisfies ServiceError; } @@ -333,7 +333,7 @@ export const disconnectMcpServer = async (serverId: string) => sew(() => return { statusCode: StatusCodes.NOT_FOUND, errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, - message: 'MCP server not found', + message: 'Connector not found', } satisfies ServiceError; } @@ -348,7 +348,7 @@ export const disconnectMcpServer = async (serverId: string) => sew(() => return { statusCode: StatusCodes.NOT_FOUND, errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, - message: 'No connection found for this MCP server.', + message: 'No connection found for this connector.', } satisfies ServiceError; } diff --git a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts index 184a0d047..89d6701cb 100644 --- a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts +++ b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts @@ -18,7 +18,7 @@ export function useConnectMcp() { if (isServiceError(result)) { toast({ - description: `Failed to connect MCP server. ${result.message}`, + description: `Failed to connect connector. ${result.message}`, }); setLoadingServerId(null); return; @@ -28,7 +28,7 @@ export function useConnectMcp() { window.location.href = result.authorizationUrl; } else { toast({ - description: 'MCP server is already connected.', + description: 'Connector is already connected.', }); await invalidateMcpConfigurationQueries(queryClient); setLoadingServerId(null); diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx index 6a484a083..752f73ff8 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -19,7 +19,7 @@ import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AlertTriangleIcon, Loader2Icon, PlusCircleIcon, PlusIcon, RefreshCwIcon, ServerIcon, SettingsIcon } from "lucide-react"; +import { AlertTriangleIcon, CableIcon, Loader2Icon, PlusCircleIcon, PlusIcon, RefreshCwIcon, SettingsIcon } from "lucide-react"; import { PlusButtonInfoCard } from "./plusButtonInfoCard"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -82,7 +82,7 @@ export const ChatBoxPlusButton = ({ queryFn: async () => { const result = await getMcpServersWithStatus(); if (isServiceError(result)) { - throw new Error("Failed to load MCP servers"); + throw new Error("Failed to load connectors"); } return result; }, @@ -156,7 +156,7 @@ export const ChatBoxPlusButton = ({ if (isServiceError(result)) { clearMcpOAuthDraft(); toast({ - description: `Failed to connect MCP server. ${result.message}`, + description: `Failed to connect connector. ${result.message}`, variant: "destructive", }); setConnectingServerId(null); @@ -169,7 +169,7 @@ export const ChatBoxPlusButton = ({ } clearMcpOAuthDraft(); - toast({ description: 'MCP server is already connected.' }); + toast({ description: 'Connector is already connected.' }); await queryClient.invalidateQueries({ queryKey: mcpQueryKeys.serversWithStatus }); if (!isMountedRef.current) { return; @@ -182,7 +182,7 @@ export const ChatBoxPlusButton = ({ clearMcpOAuthDraft(); toast({ - description: "Failed to connect MCP server.", + description: "Failed to connect connector.", variant: "destructive", }); setConnectingServerId(null); @@ -214,8 +214,8 @@ export const ChatBoxPlusButton = ({ e.preventDefault()}> - - MCP Servers + + Connectors {isError && !hasServers ? ( @@ -231,11 +231,11 @@ export const ChatBoxPlusButton = ({ ) : isLoading ? ( - Loading MCP servers... + Loading connectors... ) : !hasServers ? ( - No MCP servers available + No connectors available ) : ( <> @@ -292,10 +292,10 @@ export const ChatBoxPlusButton = ({ router.push(`/settings/mcpServers`)} + onSelect={() => router.push(`/settings/accountAskAgent`)} > - Manage MCP servers + Manage connectors diff --git a/packages/web/src/features/chat/components/chatBox/plusButtonInfoCard.tsx b/packages/web/src/features/chat/components/chatBox/plusButtonInfoCard.tsx index d98cc9295..36d3eb940 100644 --- a/packages/web/src/features/chat/components/chatBox/plusButtonInfoCard.tsx +++ b/packages/web/src/features/chat/components/chatBox/plusButtonInfoCard.tsx @@ -8,8 +8,8 @@ export const PlusButtonInfoCard = () => {

Extra Features

- Add MCP servers, include files and more. + Add connectors, include files and more.
); -}; \ No newline at end of file +}; diff --git a/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx index 0c74fe72f..ea207e66a 100644 --- a/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx +++ b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx @@ -15,8 +15,8 @@ export const McpFailedServersBanner = ({ serverNames, isVisible, onClose }: McpF } const message = serverNames.length === 1 - ? `MCP server "${serverNames[0]}" failed to load tools` - : `${serverNames.length} MCP servers failed to load tools`; + ? `Connector "${serverNames[0]}" failed to load tools` + : `${serverNames.length} connectors failed to load tools`; return (
@@ -40,4 +40,4 @@ export const McpFailedServersBanner = ({ serverNames, isVisible, onClose }: McpF
); -}; \ No newline at end of file +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx index 3711e22bd..58bcf4e90 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx @@ -24,7 +24,7 @@ export const ToolSearchToolComponent = ({ query, results }: ToolSearchToolCompon
- Searched MCP tools: {query} + Searched connector tools: {query} {results.length} result{results.length === 1 ? '' : 's'} diff --git a/packages/web/src/features/chat/mcpOAuthDraft.test.ts b/packages/web/src/features/chat/mcpOAuthDraft.test.ts index 93c9281c3..6f81f644e 100644 --- a/packages/web/src/features/chat/mcpOAuthDraft.test.ts +++ b/packages/web/src/features/chat/mcpOAuthDraft.test.ts @@ -36,7 +36,7 @@ describe('MCP OAuth draft persistence', () => { test('normalizes chat paths and strips OAuth status params', () => { expect(normalizeMcpOAuthDraftPath('/chat/thread-1?scope=sourcebot&status=connected&server=Linear')).toBe('/chat/thread-1?scope=sourcebot'); - expect(normalizeMcpOAuthDraftPath('/settings/mcpServers')).toBeUndefined(); + expect(normalizeMcpOAuthDraftPath('/settings/accountAskAgent')).toBeUndefined(); expect(normalizeMcpOAuthDraftPath('https://evil.example.com/chat')).toBeUndefined(); expect(normalizeMcpOAuthDraftPath('//evil.example.com/chat')).toBeUndefined(); }); diff --git a/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts b/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts index 321d9ee3d..8b3b8a0fe 100644 --- a/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts +++ b/packages/web/src/features/mcp/mcpOAuthReturnTo.test.ts @@ -11,6 +11,10 @@ describe('MCP OAuth return paths', () => { expect(normalizeMcpOAuthReturnTo('/chat/thread-1?foo=bar')).toBe('/chat/thread-1?foo=bar'); }); + test('allows connector settings return paths', () => { + expect(normalizeMcpOAuthReturnTo('/settings/accountAskAgent?status=connected')).toBe('/settings/accountAskAgent?status=connected'); + }); + test('rejects external and unrelated return paths', () => { expect(normalizeMcpOAuthReturnTo('https://evil.example.com/chat')).toBeUndefined(); expect(normalizeMcpOAuthReturnTo('//evil.example.com/chat')).toBeUndefined(); diff --git a/packages/web/src/features/mcp/mcpOAuthReturnTo.ts b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts index e46b5805e..0841455fa 100644 --- a/packages/web/src/features/mcp/mcpOAuthReturnTo.ts +++ b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts @@ -2,7 +2,7 @@ const MCP_OAUTH_STATE_PREFIX = 'sourcebot_mcp.'; const MCP_OAUTH_STATE_BASE_URL = 'https://sourcebot.local'; function isAllowedMcpOAuthReturnPath(pathname: string): boolean { - return pathname === '/chat' || pathname.startsWith('/chat/') || pathname === '/settings/mcpServers'; + return pathname === '/chat' || pathname.startsWith('/chat/') || pathname === '/settings/accountAskAgent'; } export function normalizeMcpOAuthReturnTo(returnTo: unknown): string | undefined { From c0e922582a7e86a650feb3a9021c55ed38f849ff Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Tue, 26 May 2026 17:30:55 -0700 Subject: [PATCH 17/40] feat(web): redesign workspace Ask Agent settings page Redesign the workspace Ask Agent page with card-based layout, 3-stat strip, connector rows with status indicators, kebab menu, and Connect button with OAuth flow. Extract shared ConnectorRowInfo component for reuse between workspace and account settings pages. --- .../accountAskAgent/accountAskAgentPage.tsx | 47 +- .../(app)/settings/workspaceAskAgent/page.tsx | 20 +- .../prefabConnectorPopover.tsx | 20 +- .../workspaceAskAgentPage.tsx | 581 +++++++++++------- .../mcp/components/connectMcpButton.tsx | 15 +- .../mcp/components/connectorRowInfo.tsx | 41 ++ .../ee/features/mcp/hooks/useConnectMcp.ts | 8 +- 7 files changed, 459 insertions(+), 273 deletions(-) create mode 100644 packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index a5633a578..fbcc5dd26 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -16,9 +16,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; import { useConnectMcp } from "@/ee/features/mcp/hooks/useConnectMcp"; import { disconnectMcpServer } from "@/ee/features/mcp/actions"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; @@ -26,10 +27,6 @@ import { cn, isServiceError } from "@/lib/utils"; type FilterTab = "all" | "connected"; -function displayUrl(url: string) { - return url.replace(/^https?:\/\//, ""); -} - function pluralize(count: number, singular: string, plural = `${singular}s`) { return count === 1 ? singular : plural; } @@ -179,6 +176,7 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe Manage your personal Ask Agent setup.

+

Connectors

@@ -201,6 +199,8 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe

+ +

Connectors

@@ -291,16 +291,12 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe visibleConnected.map((server) => ( -
- -
-
-

- {server.name || server.serverUrl} -

-

- {displayUrl(server.serverUrl)} -

+
{server.isConnected && ( <> @@ -315,7 +311,7 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe )}
-
+
+ {children ?? ( + + )} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index ef35875bb..b8507d073 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -1,36 +1,72 @@ 'use client'; -import { useState } from "react"; -import { getMcpConfiguration } from "@/app/api/(client)/client"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { getMcpConfiguration, getMcpServersWithStatus } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; +import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { isServiceError } from "@/lib/utils"; +import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangleIcon, CableIcon, Loader2, MinusIcon, PlusIcon } from "lucide-react"; +import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react"; import { PrefabConnectorPopover } from "./prefabConnectorPopover"; import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; +import type { McpConfigurationServer } from "@/ee/features/mcp/types"; function pluralize(count: number, singular: string, plural = `${singular}s`) { return count === 1 ? singular : plural; } -export function WorkspaceAskAgentPage() { +function clearCallbackParams() { + const url = new URL(window.location.href); + url.searchParams.delete('status'); + url.searchParams.delete('server'); + url.searchParams.delete('message'); + window.history.replaceState({}, '', url.toString()); +} + +interface WorkspaceAskAgentPageProps { + callbackStatus?: string; + callbackServer?: string; + callbackMessage?: string; +} + +export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage }: WorkspaceAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); + const didHandleCallbackRef = useRef(false); + + useEffect(() => { + if (didHandleCallbackRef.current) { + return; + } + if (callbackStatus === 'connected') { + didHandleCallbackRef.current = true; + toast({ description: `Successfully connected${callbackServer ? ` to ${callbackServer}` : ''}.` }); + clearCallbackParams(); + } else if (callbackStatus === 'error') { + didHandleCallbackRef.current = true; + toast({ title: "Connection failed", description: callbackMessage ?? 'Failed to connect connector.', variant: "destructive" }); + clearCallbackParams(); + } + }, [callbackStatus, callbackServer, callbackMessage, toast]); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [newServerName, setNewServerName] = useState(""); const [newServerUrl, setNewServerUrl] = useState(""); @@ -40,6 +76,7 @@ export function WorkspaceAskAgentPage() { const [clientSecret, setClientSecret] = useState(""); const [isCreating, setIsCreating] = useState(false); const [deletingServerId, setDeletingServerId] = useState(null); + const [serverToDelete, setServerToDelete] = useState(null); const { data, isLoading, isError } = useQuery({ queryKey: mcpQueryKeys.configuration, @@ -52,6 +89,28 @@ export function WorkspaceAskAgentPage() { }, }); + const { data: serversWithStatus } = useQuery({ + queryKey: mcpQueryKeys.serversWithStatus, + queryFn: async () => { + const result = await getMcpServersWithStatus(); + if (isServiceError(result)) { + throw new Error("Failed to load connector status"); + } + if (!Array.isArray(result)) { + throw new Error("Unexpected response from connector status endpoint"); + } + return result; + }, + }); + + const myStatusByServerId = useMemo(() => { + const map = new Map(); + for (const s of serversWithStatus ?? []) { + map.set(s.id, { isConnected: s.isConnected, isAuthExpired: s.isAuthExpired }); + } + return map; + }, [serversWithStatus]); + const servers = data?.servers ?? []; const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; const canCreateConnectors = data?.isOAuthAvailable === true; @@ -183,268 +242,342 @@ export function WorkspaceAskAgentPage() { try { const result = await deleteMcpServer(serverId); if (isServiceError(result)) { - toast({ title: "Error", description: `Failed to delete connector: ${result.message}`, variant: "destructive" }); + toast({ title: "Error", description: `Failed to remove connector: ${result.message}`, variant: "destructive" }); return; } await invalidateMcpConfigurationQueries(queryClient); + setServerToDelete(null); } catch (error) { - toast({ title: "Error", description: `Failed to delete connector: ${error}`, variant: "destructive" }); + toast({ title: "Error", description: `Failed to remove connector: ${error}`, variant: "destructive" }); } finally { setDeletingServerId(null); } }; + const handleCopyUrl = (serverUrl: string) => { + navigator.clipboard.writeText(serverUrl); + toast({ title: "Copied", description: "Connector URL copied to clipboard." }); + }; + if (isError) { return
Error loading Ask Agent settings
; } + const prefabPopoverProps = { + configuredServerUrls: servers.map((s) => s.serverUrl), + disabled: isCreating, + onSelectCustomUrl: handleOpenCustomUrlDialog, + onSelectPrefabServer: handleCreatePrefabServer, + }; + return (
+ {/* Page header */}

Ask Agent

-

- Customize Ask Agent for your workspace. +

+ Configure what external tools Ask Agent can use across this workspace.

+ + + {/* OAuth unavailable warning */} {!isLoading && isOAuthUnavailable && ( - - - -
-

Connector OAuth is unavailable

-

- You can remove existing approved connectors and stored credentials, but cannot add new connectors. -

-
-
-
+
+ +
+

Connector OAuth is unavailable

+

+ You can remove existing approved connectors and stored credentials, but cannot add new connectors. +

+
+
)} -
+ {/* Connectors section */} +

Connectors

-

- Connectors are MCP servers that let Ask Agent use approved external tools. +

+ Connectors are MCP servers that let Ask Agent use approved external tools alongside your indexed code.

- - -
-
-

Saved connector connections

-

- Current workspace members with saved connector credentials. -

-
+ {/* 3-stat strip */} +
+ + +

Allowed Connectors

{isLoading ? ( - + ) : ( -

- {totalSavedConnectionCount} {pluralize(totalSavedConnectionCount, "connection")} -

+

{servers.length}

)} -
- - +

approved for workspace

+ + + + +

Saved Connections

+ {isLoading ? ( + + ) : ( +

{totalSavedConnectionCount}

+ )} +

+ {totalSavedConnectionCount === 1 ? "member has" : "members have"} credentials saved +

+
+
+ + +

Active in Last 7d

+

+

tool calls

+
+
+
- - + {/* Allowed connectors subsection */} +
+
- {isLoading ? "Allowed connectors" : `${servers.length} allowed ${pluralize(servers.length, "connector")}`} - +

Allowed connectors

+

{isOAuthUnavailable ? "Remove existing connector approvals and their stored credentials." : "Approve connector URLs that workspace members can connect to."} - +

- {canCreateConnectors ? ( - <> - server.serverUrl)} - disabled={isCreating} - onSelectCustomUrl={handleOpenCustomUrlDialog} - onSelectPrefabServer={handleCreatePrefabServer} - /> - - - - Add Connector - - Add a workspace-approved connector that members can use with Ask Agent. - - -
-
- - setNewServerName(event.target.value)} - placeholder="e.g. Linear" - /> -
-
- - setNewServerUrl(event.target.value)} - placeholder="https://mcp.linear.app/mcp" - /> -
-
- - - - -
-
- { - if (!open) { - handleCloseClientCredentialsDialog(); - return; - } - - setIsClientCredentialsDialogOpen(true); - }}> - - - OAuth Client Credentials Required - - This connector does not advertise dynamic client registration. Provide OAuth client credentials from a pre-registered app before members can connect to it. - - -
- {pendingClientCredentialsServer && ( -
-

{pendingClientCredentialsServer.name}

-

{pendingClientCredentialsServer.serverUrl}

-
- )} -
- - setClientId(event.target.value)} - placeholder="OAuth client ID" - /> -
-
- - setClientSecret(event.target.value)} - placeholder="OAuth client secret" - /> -
-
- - - - -
-
- - ) : ( - + {canCreateConnectors && ( + + + )} - - +
+ + {/* Connector list */} +
{isLoading ? ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- -
- - + Array.from({ length: 3 }).map((_, i) => ( + + + +
+ +
- -
- ))} -
+ + + + )) ) : servers.length === 0 ? ( -
-
- -
-

No connectors configured yet

-

- {isOAuthUnavailable - ? "Connector OAuth is unavailable on this Sourcebot instance." - : "Add a workspace-approved connector so members can use it with Ask Agent."} -

-
+ + +
+ +
+

No connectors configured yet

+

+ {isOAuthUnavailable + ? "Connector OAuth is unavailable on this Sourcebot instance." + : "Add a workspace-approved connector so members can use it with Ask Agent."} +

+
+
) : ( -
- {servers.map((server) => ( -
-
- -
-
-

{server.name || server.serverUrl}

-

{server.serverUrl}

-
-

- {server.savedConnectionCount} {pluralize(server.savedConnectionCount, "saved connection")} -

- - - - - - - Delete Connector - - Are you sure you want to remove {server.name || server.serverUrl}? Workspace members will lose access and stored credentials for this connector. - - - - Cancel - handleDelete(server.id)} - disabled={deletingServerId === server.id} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + servers.map((server) => ( + + + +
+ 0 ? "text-green-600 dark:text-green-400" : "text-muted-foreground" + )}> + 0 ? "bg-green-500/80" : "bg-muted-foreground" + )} /> + {server.savedConnectionCount > 0 + ? `${server.savedConnectionCount} ${pluralize(server.savedConnectionCount, "member")} connected` + : "No members connected"} + + + + — tools + +
+
+
+ {!myStatusByServerId.get(server.id)?.isConnected && ( + + )} + + + + + + handleCopyUrl(server.serverUrl)}> + + Copy URL + + setServerToDelete(server)} > - {deletingServerId === server.id ? "Deleting..." : "Delete"} - - - - -
- ))} -
+ + Remove + + + +
+ + + )) )} - - +
+
+ + {/* Delete confirmation */} + { if (!open) { setServerToDelete(null); } }}> + + + Remove Connector + + Are you sure you want to remove {serverToDelete?.name || serverToDelete?.serverUrl}? Workspace members will lose access and stored credentials for this connector. + + + + Cancel + { if (serverToDelete) { handleDelete(serverToDelete.id); } }} + disabled={deletingServerId !== null} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deletingServerId ? "Removing..." : "Remove"} + + + + + + {/* Add connector dialog */} + + + + Add Connector + + Add a workspace-approved connector that members can use with Ask Agent. + + +
+
+ + setNewServerName(event.target.value)} + placeholder="e.g. Linear" + /> +
+
+ + setNewServerUrl(event.target.value)} + placeholder="https://mcp.linear.app/mcp" + /> +
+
+ + + + +
+
+ + {/* OAuth client credentials dialog */} + { + if (!open) { + handleCloseClientCredentialsDialog(); + return; + } + + setIsClientCredentialsDialogOpen(true); + }}> + + + OAuth Client Credentials Required + + This connector does not advertise dynamic client registration. Provide OAuth client credentials from a pre-registered app before members can connect to it. + + +
+ {pendingClientCredentialsServer && ( +
+

{pendingClientCredentialsServer.name}

+

{pendingClientCredentialsServer.serverUrl}

+
+ )} +
+ + setClientId(event.target.value)} + placeholder="OAuth client ID" + /> +
+
+ + setClientSecret(event.target.value)} + placeholder="OAuth client secret" + /> +
+
+ + + + +
+
); } diff --git a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx index 417392734..c8969af14 100644 --- a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx +++ b/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx @@ -1,7 +1,7 @@ 'use client'; import { LoadingButton } from '@/components/ui/loading-button'; -import { ExternalLink, PlusIcon } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import type { ButtonProps } from '@/components/ui/button'; import { useConnectMcp } from '@/ee/features/mcp/hooks/useConnectMcp'; @@ -10,24 +10,27 @@ interface ConnectMcpButtonProps { isConnected?: boolean; isAuthExpired?: boolean; size?: ButtonProps['size']; + variant?: ButtonProps['variant']; + returnTo?: string; + className?: string; } -export function ConnectMcpButton({ serverId, isConnected, isAuthExpired, size }: ConnectMcpButtonProps) { - const { connect, loadingServerId } = useConnectMcp(); +export function ConnectMcpButton({ serverId, isConnected, isAuthExpired, size, variant, returnTo, className }: ConnectMcpButtonProps) { + const { connect, loadingServerId } = useConnectMcp({ returnTo }); const loading = loadingServerId === serverId; const isSuggested = !isConnected && !isAuthExpired; const buttonLabel = isSuggested ? "Connect" : "Reconnect"; - const buttonVariant = isConnected ? "outline" as const : undefined; + const defaultVariant = isConnected ? "outline" as const : undefined; return ( connect(serverId)} loading={loading} - variant={buttonVariant} + variant={variant ?? defaultVariant} size={size} + className={className} > - {isSuggested && } {buttonLabel} {!isSuggested && } diff --git a/packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx b/packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx new file mode 100644 index 000000000..b7dd504f9 --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx @@ -0,0 +1,41 @@ +import { McpFavicon } from "./mcpFavicon"; +import { cn } from "@/lib/utils"; + +export function getDisplayServerUrl(serverUrl: string) { + try { + const url = new URL(serverUrl); + return `${url.host}${url.pathname}${url.search}`.replace(/\/$/, ""); + } catch { + return serverUrl; + } +} + +interface ConnectorRowInfoProps { + faviconUrl: string | undefined; + name: string; + serverUrl: string; + children?: React.ReactNode; + size?: 'sm' | 'default'; +} + +export function ConnectorRowInfo({ faviconUrl, name, serverUrl, children, size = 'default' }: ConnectorRowInfoProps) { + return ( + <> +
+ +
+
+

+ {name || serverUrl} +

+

+ {getDisplayServerUrl(serverUrl)} +

+ {children} +
+ + ); +} diff --git a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts index 89d6701cb..e9e95ee20 100644 --- a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts +++ b/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts @@ -7,14 +7,18 @@ import { connectMcpToAsk } from '@/app/api/(client)/client'; import { invalidateMcpConfigurationQueries } from '@/ee/features/mcp/queryKeys'; import { isServiceError } from '@/lib/utils'; -export function useConnectMcp() { +interface UseConnectMcpOptions { + returnTo?: string; +} + +export function useConnectMcp(options?: UseConnectMcpOptions) { const [loadingServerId, setLoadingServerId] = useState(null); const { toast } = useToast(); const queryClient = useQueryClient(); const connect = async (serverId: string) => { setLoadingServerId(serverId); - const result = await connectMcpToAsk({ serverId }); + const result = await connectMcpToAsk({ serverId, returnTo: options?.returnTo }); if (isServiceError(result)) { toast({ From 29edd6c917181eb9ca30c15740d651e99240fd24 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Tue, 26 May 2026 18:43:24 -0700 Subject: [PATCH 18/40] feat(web): add workspace connector config link to chat toolbar Add "Configure connectors" link to the chat toolbar's Connectors submenu, pointing to /settings/workspaceAskAgent for workspace-level connector management. Keep existing "Manage connectors" link to /settings/accountAskAgent for personal connector setup. --- .../chat/components/chatBox/chatBoxPlusButton.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx index 752f73ff8..5c776aa1e 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -293,9 +293,16 @@ export const ChatBoxPlusButton = ({ router.push(`/settings/accountAskAgent`)} + > + + My connectors + + router.push(`/settings/workspaceAskAgent`)} > - Manage connectors + Workspace connectors From 49d59062c4035ecaa9e0faef71a37b0ffd998bfd Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 27 May 2026 09:28:16 -0700 Subject: [PATCH 19/40] Add MCP connector tool metadata --- .../accountAskAgentPage.test.tsx | 19 +- .../accountAskAgent/accountAskAgentPage.tsx | 298 +++++++++++++----- .../(app)/settings/accountAskAgent/page.tsx | 4 + .../settings/workspaceAskAgent/page.test.tsx | 9 +- .../workspaceAskAgentPage.tsx | 223 +++++++++---- packages/web/src/app/api/(client)/client.ts | 13 +- .../api/(server)/ee/askmcp/servers/route.ts | 24 +- .../(server)/ee/askmcp/tools/route.test.ts | 75 +++++ .../app/api/(server)/ee/askmcp/tools/route.ts | 27 ++ .../connectorToolDisclosure.test.tsx | 149 +++++++++ .../components/connectorToolDisclosure.tsx | 175 ++++++++++ .../ee/features/mcp/connectionStatus.test.ts | 81 +++++ .../src/ee/features/mcp/connectionStatus.ts | 74 +++++ .../features/mcp/hooks/useMcpToolMetadata.ts | 70 ++++ .../ee/features/mcp/mcpClientFactory.test.ts | 24 +- .../src/ee/features/mcp/mcpClientFactory.ts | 25 +- .../ee/features/mcp/mcpToolMetadata.test.ts | 166 ++++++++++ .../src/ee/features/mcp/mcpToolMetadata.ts | 269 ++++++++++++++++ .../web/src/ee/features/mcp/queryKeys.test.ts | 3 +- packages/web/src/ee/features/mcp/queryKeys.ts | 2 + packages/web/src/ee/features/mcp/types.ts | 24 ++ packages/web/src/ee/features/mcp/utils.ts | 4 + 22 files changed, 1542 insertions(+), 216 deletions(-) create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts create mode 100644 packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts create mode 100644 packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx create mode 100644 packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx create mode 100644 packages/web/src/ee/features/mcp/connectionStatus.test.ts create mode 100644 packages/web/src/ee/features/mcp/connectionStatus.ts create mode 100644 packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts create mode 100644 packages/web/src/ee/features/mcp/mcpToolMetadata.ts diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx index a56104170..6fb241298 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx @@ -3,12 +3,13 @@ import { cleanup, render, screen } from '@testing-library/react'; vi.mock('@/app/api/(client)/client', () => ({ getMcpServersWithStatus: vi.fn(), + getMcpServerTools: vi.fn(), })); vi.mock('@/ee/features/mcp/actions', () => ({ disconnectMcpServer: vi.fn(), })); -const { AccountAskAgentEmptyState } = await import('./accountAskAgentPage'); +const { AccountAskAgentEmptyState, AccountAskAgentOAuthUnavailableState } = await import('./accountAskAgentPage'); afterEach(() => { cleanup(); @@ -31,3 +32,19 @@ describe('AccountAskAgentEmptyState', () => { expect(screen.queryByRole('link', { name: /Open Workspace Ask Agent/ })).toBeNull(); }); }); + +describe('AccountAskAgentOAuthUnavailableState', () => { + test('points owners to workspace cleanup settings', () => { + render(); + + expect(screen.getByText('Connector OAuth is unavailable')).toBeTruthy(); + expect(screen.getByRole('link', { name: /Open Workspace Ask Agent/ }).getAttribute('href')).toBe('/settings/workspaceAskAgent'); + }); + + test('hides workspace cleanup link from members', () => { + render(); + + expect(screen.getByText('Connector setup is unavailable on this Sourcebot instance.')).toBeTruthy(); + expect(screen.queryByRole('link', { name: /Open Workspace Ask Agent/ })).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index fbcc5dd26..f8cd379f5 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Collapsible } from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; @@ -20,17 +21,18 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; +import { ConnectorToolList, ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; import { useConnectMcp } from "@/ee/features/mcp/hooks/useConnectMcp"; +import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { disconnectMcpServer } from "@/ee/features/mcp/actions"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { pluralize } from "@/ee/features/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; +import type { McpServerWithStatus } from "@/app/api/(server)/ee/askmcp/servers/route"; +import type { ServerToolsEntry } from "@/ee/features/mcp/types"; type FilterTab = "all" | "connected"; -function pluralize(count: number, singular: string, plural = `${singular}s`) { - return count === 1 ? singular : plural; -} - function clearCallbackParams() { const url = new URL(window.location.href); url.searchParams.delete('status'); @@ -44,6 +46,7 @@ interface AccountAskAgentPageProps { callbackServer?: string; callbackMessage?: string; canManageConnectors: boolean; + isOAuthAvailable: boolean; } export function AccountAskAgentEmptyState({ canManageConnectors }: { canManageConnectors: boolean }) { @@ -74,7 +77,169 @@ export function AccountAskAgentEmptyState({ canManageConnectors }: { canManageCo ); } -export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMessage, canManageConnectors }: AccountAskAgentPageProps) { +export function AccountAskAgentOAuthUnavailableState({ canManageConnectors }: { canManageConnectors: boolean }) { + return ( + + +
+ +
+

Connector OAuth is unavailable

+

+ {canManageConnectors + ? "Open Workspace Ask Agent to remove existing connector approvals and stored credentials." + : "Connector setup is unavailable on this Sourcebot instance."} +

+ {canManageConnectors && ( + + )} +
+
+ ); +} + +interface AccountConnectedConnectorCardProps { + server: McpServerWithStatus; + toolEntry?: ServerToolsEntry; + isToolsLoading: boolean; + isToolsError: boolean; + onRetryTools: () => void; + onReconnect: (serverId: string) => void; + onDisconnect: (server: McpServerWithStatus) => void; + disconnectingServerId: string | null; +} + +function AccountConnectedConnectorCard({ + server, + toolEntry, + isToolsLoading, + isToolsError, + onRetryTools, + onReconnect, + onDisconnect, + disconnectingServerId, +}: AccountConnectedConnectorCardProps) { + const [isToolListOpen, setIsToolListOpen] = useState(false); + const availableToolEntry = server.isConnected ? toolEntry : undefined; + const hasToolList = availableToolEntry?.status === 'available'; + const isLoadingToolsForServer = server.isConnected && !availableToolEntry && isToolsLoading; + + return ( + setIsToolListOpen(hasToolList ? open : false)} + > + + +
+ +
+ {server.isConnected && ( + + + Connected + + )} + {server.isAuthExpired && ( + + + Authorization expired + + )} + +
+
+
+ + + + + + + onReconnect(server.id)}> + + Reconnect + + onDisconnect(server)} + > + + {disconnectingServerId === server.id ? "Disconnecting..." : "Disconnect"} + + + +
+
+ + + + + ); +} + +function AccountSuggestedConnectorCard({ server }: { server: McpServerWithStatus }) { + return ( + + + +
+ +
+
+ +
+
+ ); +} + +export function AccountAskAgentPage({ + callbackStatus, + callbackServer, + callbackMessage, + canManageConnectors, + isOAuthAvailable, +}: AccountAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); const didHandleCallbackRef = useRef(false); @@ -108,6 +273,7 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe } return result; }, + enabled: isOAuthAvailable, }); const connectedServers = useMemo( @@ -144,6 +310,16 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe const visibleConnected = filteredConnected; const visibleSuggested = activeTab === "all" ? filteredSuggested : []; + const activeConnectedServerCount = useMemo( + () => servers.filter((s) => s.isConnected).length, + [servers], + ); + const { + isToolsLoading, + isToolsError, + refetchTools, + toolsByServerId, + } = useMcpToolMetadata(isOAuthAvailable, activeConnectedServerCount); const handleDisconnect = async (serverId: string) => { setDisconnectingServerId(serverId); @@ -163,6 +339,29 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe } }; + if (!isOAuthAvailable) { + return ( +
+
+

Ask Agent

+

+ Manage your personal Ask Agent setup. +

+
+ +
+
+

Connectors

+

+ Manage workspace-approved connectors for use with Ask Agent. +

+
+ +
+
+ ); + } + if (isError) { return
Error loading connectors
; } @@ -289,62 +488,20 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe ) : ( visibleConnected.map((server) => ( - - - -
- {server.isConnected && ( - <> - - Connected - - )} - {server.isAuthExpired && ( - <> - - Authorization expired - - )} -
-
-
- - - - - - - reconnectMcp(server.id)}> - - Reconnect - - setConfirmDisconnectServer({ - id: server.id, - name: server.name || server.serverUrl, - })} - > - - {disconnectingServerId === server.id ? "Disconnecting..." : "Disconnect"} - - - -
-
-
+ { void refetchTools(); }} + onReconnect={reconnectMcp} + onDisconnect={(serverToDisconnect) => setConfirmDisconnectServer({ + id: serverToDisconnect.id, + name: serverToDisconnect.name || serverToDisconnect.serverUrl, + })} + disconnectingServerId={disconnectingServerId} + /> )) )}
@@ -373,24 +530,7 @@ export function AccountAskAgentPage({ callbackStatus, callbackServer, callbackMe ) : ( visibleSuggested.map((server) => ( - - - - - - + )) )}
diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx index 9db22ac70..078e67288 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx @@ -1,4 +1,5 @@ import { AccountAskAgentPage } from "./accountAskAgentPage"; +import { hasEntitlement } from "@/lib/entitlements"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; @@ -12,12 +13,15 @@ interface PageProps extends Record { export default authenticatedPage(async ({ role }, { searchParams }) => { const { status, server, message } = await searchParams; + const isOAuthAvailable = await hasEntitlement('oauth'); + return ( ); }); diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx index 9e88df1c7..8d5be7a56 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.test.tsx @@ -18,7 +18,8 @@ vi.mock('@/lib/entitlements', () => ({ hasEntitlement: mocks.hasEntitlement, })); vi.mock('@/middleware/authenticatedPage', () => ({ - authenticatedPage: vi.fn((page: (auth: typeof mocks.authContext) => Promise) => () => page(mocks.authContext)), + authenticatedPage: vi.fn((page: (auth: typeof mocks.authContext, props: { searchParams: Promise> }) => Promise) => + (props: { searchParams: Promise> }) => page(mocks.authContext, props)), })); vi.mock('./workspaceAskAgentPage', () => ({ WorkspaceAskAgentPage: () =>
Workspace Ask Agent client
, @@ -38,7 +39,7 @@ afterEach(() => { describe('Ask Agent settings page', () => { test('renders the client configuration page when OAuth is available', async () => { - render(await Page({})); + render(await Page({ searchParams: Promise.resolve({}) })); expect(screen.getByText('Workspace Ask Agent client')).toBeTruthy(); }); @@ -47,7 +48,7 @@ describe('Ask Agent settings page', () => { mocks.hasEntitlement.mockResolvedValue(false); mocks.authContext.prisma.mcpServer.count.mockResolvedValue(1); - render(await Page({})); + render(await Page({ searchParams: Promise.resolve({}) })); expect(screen.getByText('Workspace Ask Agent client')).toBeTruthy(); expect(mocks.authContext.prisma.mcpServer.count).toHaveBeenCalledWith({ @@ -58,7 +59,7 @@ describe('Ask Agent settings page', () => { test('renders an unavailable message when OAuth is not available and no cleanup is needed', async () => { mocks.hasEntitlement.mockResolvedValue(false); - render(await Page({})); + render(await Page({ searchParams: Promise.resolve({}) })); expect(screen.getByText('Ask Agent Connectors Are Unavailable')).toBeTruthy(); expect(screen.queryByText('Workspace Ask Agent client')).toBeNull(); diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index b8507d073..7458bd947 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Collapsible } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -22,17 +23,16 @@ import { Skeleton } from "@/components/ui/skeleton"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; +import { ConnectorToolList, ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; +import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { pluralize } from "@/ee/features/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react"; +import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { PrefabConnectorPopover } from "./prefabConnectorPopover"; import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; -import type { McpConfigurationServer } from "@/ee/features/mcp/types"; - -function pluralize(count: number, singular: string, plural = `${singular}s`) { - return count === 1 ? singular : plural; -} +import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/mcp/types"; function clearCallbackParams() { const url = new URL(window.location.href); @@ -48,6 +48,128 @@ interface WorkspaceAskAgentPageProps { callbackMessage?: string; } +type WorkspaceConnectorStatus = { + isConnected: boolean; + isAuthExpired: boolean; +}; + +interface WorkspaceConnectorCardProps { + server: McpConfigurationServer; + status?: WorkspaceConnectorStatus; + isOAuthAvailable: boolean; + isStatusLoading: boolean; + isStatusError: boolean; + toolEntry?: ServerToolsEntry; + isToolsLoading: boolean; + isToolsError: boolean; + onRetryTools: () => void; + onCopyUrl: (serverUrl: string) => void; + onDelete: (server: McpConfigurationServer) => void; +} + +function WorkspaceConnectorCard({ + server, + status, + isOAuthAvailable, + isStatusLoading, + isStatusError, + toolEntry, + isToolsLoading, + isToolsError, + onRetryTools, + onCopyUrl, + onDelete, +}: WorkspaceConnectorCardProps) { + const [isToolListOpen, setIsToolListOpen] = useState(false); + const isConnected = status?.isConnected === true; + const isAuthExpired = status?.isAuthExpired === true; + const isStatusUnavailable = isOAuthAvailable !== true || isStatusLoading || isStatusError || !status; + const availableToolEntry = isConnected ? toolEntry : undefined; + const hasToolList = availableToolEntry?.status === 'available'; + const isLoadingToolsForServer = isConnected && !availableToolEntry && isToolsLoading; + const showConnectButton = isOAuthAvailable && !isStatusLoading && !isStatusError && !!status && !isConnected; + + return ( + setIsToolListOpen(hasToolList ? open : false)} + > + + +
+ +
+ 0 ? "text-green-600 dark:text-green-400" : "text-muted-foreground", + )}> + 0 ? "bg-green-500/80" : "bg-muted-foreground", + )} /> + {server.savedConnectionCount > 0 + ? `${server.savedConnectionCount} ${pluralize(server.savedConnectionCount, "member")} connected` + : "No members connected"} + + +
+
+
+ {showConnectButton && ( + + )} + + + + + + onCopyUrl(server.serverUrl)}> + + Copy URL + + onDelete(server)} + > + + Remove + + + +
+
+ +
+
+
+ ); +} + export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage }: WorkspaceAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); @@ -89,7 +211,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback }, }); - const { data: serversWithStatus } = useQuery({ + const { data: serversWithStatus, isLoading: isServersWithStatusLoading, isError: isServersWithStatusError } = useQuery({ queryKey: mcpQueryKeys.serversWithStatus, queryFn: async () => { const result = await getMcpServersWithStatus(); @@ -101,6 +223,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback } return result; }, + enabled: data?.isOAuthAvailable !== false, }); const myStatusByServerId = useMemo(() => { @@ -115,6 +238,16 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; const canCreateConnectors = data?.isOAuthAvailable === true; const isOAuthUnavailable = data?.isOAuthAvailable === false; + const connectedServerCount = useMemo( + () => serversWithStatus?.filter((server) => server.isConnected).length ?? 0, + [serversWithStatus], + ); + const { + isToolsLoading, + isToolsError, + refetchTools, + toolsByServerId, + } = useMcpToolMetadata(data?.isOAuthAvailable === true, connectedServerCount); const handleCreateDialogOpenChange = (open: boolean) => { setIsCreateDialogOpen(open); @@ -392,68 +525,20 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback ) : ( servers.map((server) => ( - - - -
- 0 ? "text-green-600 dark:text-green-400" : "text-muted-foreground" - )}> - 0 ? "bg-green-500/80" : "bg-muted-foreground" - )} /> - {server.savedConnectionCount > 0 - ? `${server.savedConnectionCount} ${pluralize(server.savedConnectionCount, "member")} connected` - : "No members connected"} - - - - — tools - -
-
-
- {!myStatusByServerId.get(server.id)?.isConnected && ( - - )} - - - - - - handleCopyUrl(server.serverUrl)}> - - Copy URL - - setServerToDelete(server)} - > - - Remove - - - -
-
-
+ { void refetchTools(); }} + onCopyUrl={handleCopyUrl} + onDelete={setServerToDelete} + /> )) )}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 22c22e974..083723ef4 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -33,7 +33,7 @@ import type { import { OffersResponse } from "@/ee/features/lighthouse/types"; import { ConnectMcpResponse } from "../(server)/ee/askmcp/connect/types"; import type { GetMcpServersResponse } from "../(server)/ee/askmcp/servers/route"; -import type { GetMcpConfigurationResponse } from "@/ee/features/mcp/types"; +import type { GetMcpConfigurationResponse, GetMcpToolsResponse } from "@/ee/features/mcp/types"; export const search = async (body: SearchRequest): Promise => { const result = await fetch("/api/search", { @@ -284,3 +284,14 @@ export const getMcpConfiguration = async (): Promise => { + const result = await fetch('/api/ee/askmcp/tools', { + method: 'GET', + headers: { + 'X-Sourcebot-Client-Source': 'sourcebot-web-client', + }, + }).then(response => response.json()); + + return result as GetMcpToolsResponse | ServiceError; +} diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index 8fe277379..d3f3f4ea9 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -3,10 +3,9 @@ import { serviceErrorResponse } from '@/lib/serviceError'; import { isServiceError } from '@/lib/utils'; import { withAuth } from '@/middleware/withAuth'; import { hasEntitlement } from '@/lib/entitlements'; -import { decryptOAuthToken } from '@sourcebot/shared'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; -import type { OAuthTokens } from '@ai-sdk/mcp'; import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; +import { getStoredMcpConnectionStatus } from '@/ee/features/mcp/connectionStatus'; import type { NextRequest } from 'next/server'; export interface McpServerWithStatus { @@ -58,22 +57,11 @@ export const GET = apiHandler(async (_request: NextRequest) => { let isConnected = false; let isAuthExpired = false; - if (userServer?.tokens) { - try { - const decrypted = decryptOAuthToken(userServer.tokens); - if (decrypted) { - const tokens: OAuthTokens = JSON.parse(decrypted); - if (tokens.refresh_token || !userServer.tokensExpiresAt) { - isConnected = true; - } else if (new Date() > userServer.tokensExpiresAt) { - isAuthExpired = true; - } else { - isConnected = true; - } - } - } catch { - // treat as not connected if decryption fails - } + const connectionStatus = getStoredMcpConnectionStatus(userServer?.tokens, userServer?.tokensExpiresAt ?? null); + if (connectionStatus.state === 'connected') { + isConnected = true; + } else if (connectionStatus.state === 'expired') { + isAuthExpired = true; } return { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts new file mode 100644 index 000000000..06f2f53b5 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + hasEntitlement: vi.fn(), + getMcpToolMetadata: vi.fn(), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); +vi.mock('@/ee/features/mcp/mcpToolMetadata', () => ({ + getMcpToolMetadata: mocks.getMcpToolMetadata, +})); + +const { GET } = await import('./route'); + +function createRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/tools', { method: 'GET' }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); + mocks.getMcpToolMetadata.mockResolvedValue([]); +}); + +describe('GET /api/ee/askmcp/tools', () => { + test('returns tool metadata for the authenticated viewer without owner-only gating', async () => { + const prisma = {}; + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + role: 'MEMBER', + prisma, + }; + mocks.getMcpToolMetadata.mockResolvedValue([ + { + status: 'available', + serverId: 'server-1', + tools: [{ name: 'search' }], + }, + ]); + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(mocks.getMcpToolMetadata).toHaveBeenCalledWith(prisma, 'user-1', 1); + expect(body).toEqual([ + { + status: 'available', + serverId: 'server-1', + tools: [{ name: 'search' }], + }, + ]); + }); + + test('returns access_denied when OAuth is unavailable', async () => { + mocks.hasEntitlement.mockResolvedValue(false); + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ error: 'access_denied' }); + expect(mocks.getMcpToolMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts new file mode 100644 index 000000000..2d6dce0a1 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts @@ -0,0 +1,27 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { withAuth } from '@/middleware/withAuth'; +import { hasEntitlement } from '@/lib/entitlements'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { getMcpToolMetadata } from '@/ee/features/mcp/mcpToolMetadata'; +import type { NextRequest } from 'next/server'; + +export const GET = apiHandler(async (_request: NextRequest) => { + if (!(await hasEntitlement('oauth'))) { + return Response.json( + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 403 }, + ); + } + + const result = await withAuth(async ({ org, user, prisma }) => { + return getMcpToolMetadata(prisma, user.id, org.id); + }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx new file mode 100644 index 000000000..9da2773f4 --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Collapsible } from '@/components/ui/collapsible'; +import { ConnectorToolList, ConnectorToolTrigger } from './connectorToolDisclosure'; +import type { ServerToolsEntry } from '@/ee/features/mcp/types'; + +afterEach(() => { + cleanup(); +}); + +function renderToolTrigger(props: React.ComponentProps) { + return render( + + + , + ); +} + +function availableEntry(overrides: Partial> = {}): Extract { + return { + status: 'available', + serverId: 'server-1', + tools: [ + { name: 'search', title: 'Search', description: 'Search issues', annotations: { readOnlyHint: true } }, + { name: 'delete_issue', description: 'Delete an issue', annotations: { destructiveHint: true, idempotentHint: true } }, + ], + ...overrides, + }; +} + +describe('ConnectorToolTrigger', () => { + test('renders an expandable count for available tools', () => { + renderToolTrigger({ + isConnected: true, + toolEntry: availableEntry(), + isOpen: false, + }); + + expect(screen.getByRole('button', { name: /2 tools/ })).toBeTruthy(); + }); + + test('uses plus count language only for list truncation', () => { + renderToolTrigger({ + isConnected: true, + toolEntry: availableEntry({ tools: [{ name: 'search' }], truncated: true }), + }); + + expect(screen.getByRole('button', { name: /1\+ tools/ })).toBeTruthy(); + }); + + test('renders unavailable state before connection-specific states', () => { + renderToolTrigger({ + isConnected: false, + isOAuthAvailable: false, + }); + + expect(screen.getByText('Tools unavailable')).toBeTruthy(); + expect(screen.queryByText('Connect to see tools')).toBeNull(); + }); + + test('renders actionable labels for disconnected and expired auth states', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('Connect to see tools')).toBeTruthy(); + + rerender( + + + , + ); + + expect(screen.getByText('Reconnect to see tools')).toBeTruthy(); + }); + + test('renders loading and retryable error states for connected servers', () => { + const onRetry = vi.fn(); + const { rerender } = render( + + + , + ); + + expect(screen.getByText('Loading tools...')).toBeTruthy(); + + rerender( + + + , + ); + + expect(screen.getByText('Tools timed out')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /Retry/ })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('maps auth_failed errors to reconnect language', () => { + renderToolTrigger({ + isConnected: true, + toolEntry: { status: 'error', serverId: 'server-1', reason: 'auth_failed' }, + }); + + expect(screen.getByText('Reconnect to see tools')).toBeTruthy(); + }); +}); + +describe('ConnectorToolList', () => { + test('renders tool details and annotation badges', () => { + render( + + + , + ); + + expect(screen.getByText('Search')).toBeTruthy(); + expect(screen.getByText('search')).toBeTruthy(); + expect(screen.getByText('Search issues')).toBeTruthy(); + expect(screen.getByText('Read-only')).toBeTruthy(); + expect(screen.getByText('Destructive')).toBeTruthy(); + expect(screen.getByText('Idempotent')).toBeTruthy(); + }); + + test('renders an empty-tools message for available servers with no tools', () => { + render( + + + , + ); + + expect(screen.getByText('No tools exposed by this connector.')).toBeTruthy(); + }); + + test('does not render list content for non-available entries', () => { + render( + + + , + ); + + expect(screen.queryByText('No tools exposed by this connector.')).toBeNull(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx new file mode 100644 index 000000000..e0ca10dca --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import { pluralize } from '@/ee/features/mcp/utils'; +import type { ServerToolsEntry, ToolMetadataErrorReason, ToolSummary } from '@/ee/features/mcp/types'; +import { ChevronDownIcon, RefreshCwIcon, WrenchIcon } from 'lucide-react'; + +function getErrorLabel(reason: ToolMetadataErrorReason) { + switch (reason) { + case 'timeout': + return 'Tools timed out'; + case 'auth_failed': + return 'Reconnect to see tools'; + case 'unsupported': + return 'Tools unsupported'; + case 'connection_failed': + case 'unknown': + return 'Tools unavailable'; + } +} + +function getToolCountLabel(entry: Extract) { + const countLabel = `${entry.tools.length}${entry.truncated ? '+' : ''}`; + const nounCount = entry.truncated ? 2 : entry.tools.length; + return `${countLabel} ${pluralize(nounCount, 'tool')}`; +} + +interface ConnectorToolTriggerProps { + isConnected: boolean; + isAuthExpired?: boolean; + isOAuthAvailable?: boolean; + isStatusUnavailable?: boolean; + toolEntry?: ServerToolsEntry; + isLoading?: boolean; + isToolsQueryError?: boolean; + isOpen?: boolean; + onRetry?: () => void; +} + +export function ConnectorToolTrigger({ + isConnected, + isAuthExpired = false, + isOAuthAvailable = true, + isStatusUnavailable = false, + toolEntry, + isLoading = false, + isToolsQueryError = false, + isOpen = false, + onRetry, +}: ConnectorToolTriggerProps) { + const availableEntry = toolEntry?.status === 'available' ? toolEntry : undefined; + const errorEntry = toolEntry?.status === 'error' ? toolEntry : undefined; + const canExpand = !!availableEntry; + + if (canExpand) { + return ( + + + + ); + } + + let label = 'Tools unavailable'; + let canRetry = false; + + if (!isOAuthAvailable || isStatusUnavailable) { + label = 'Tools unavailable'; + } else if (!isConnected && isAuthExpired) { + label = 'Reconnect to see tools'; + } else if (!isConnected) { + label = 'Connect to see tools'; + } else if (isLoading) { + label = 'Loading tools...'; + } else if (errorEntry) { + label = getErrorLabel(errorEntry.reason); + canRetry = true; + } else if (isToolsQueryError) { + label = 'Tools unavailable'; + canRetry = true; + } + + return ( + + + {label} + {canRetry && onRetry && ( + + )} + + ); +} + +function ToolHintBadges({ tool }: { tool: ToolSummary }) { + const annotations = tool.annotations; + if (!annotations) { + return null; + } + + return ( + <> + {annotations.readOnlyHint === true && ( + + Read-only + + )} + {annotations.destructiveHint === true && ( + + Destructive + + )} + {annotations.idempotentHint === true && ( + + Idempotent + + )} + + ); +} + +interface ConnectorToolListProps { + toolEntry?: ServerToolsEntry; +} + +export function ConnectorToolList({ toolEntry }: ConnectorToolListProps) { + if (toolEntry?.status !== 'available') { + return null; + } + + return ( + +
+ {toolEntry.tools.length === 0 ? ( +

No tools exposed by this connector.

+ ) : ( +
+ {toolEntry.tools.map((tool) => { + const displayName = tool.title ?? tool.name; + + return ( +
+
+ {displayName} + {tool.title && tool.title !== tool.name && ( + {tool.name} + )} + +
+ {tool.description && ( +

{tool.description}

+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/ee/features/mcp/connectionStatus.test.ts b/packages/web/src/ee/features/mcp/connectionStatus.test.ts new file mode 100644 index 000000000..d0c1b538b --- /dev/null +++ b/packages/web/src/ee/features/mcp/connectionStatus.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { OAuthTokens } from '@ai-sdk/mcp'; + +const decryptOAuthToken = vi.hoisted(() => vi.fn()); + +vi.mock('@sourcebot/shared', () => ({ + decryptOAuthToken, +})); + +const { getStoredMcpConnectionStatus, isTokenExpiredWithNoRefresh } = await import('./connectionStatus'); + +const PAST = new Date('2020-01-01'); +const FUTURE = new Date('2099-01-01'); +const TOKEN_NO_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer' }; +const TOKEN_WITH_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer', refresh_token: 'ref' }; + +beforeEach(() => { + decryptOAuthToken.mockReset(); + decryptOAuthToken.mockImplementation((value: string) => value); +}); + +describe('isTokenExpiredWithNoRefresh', () => { + test('returns true when an access token is expired and has no refresh token', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, PAST)).toBe(true); + }); + + test('returns false when a refresh token is present', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_WITH_REFRESH, PAST)).toBe(false); + }); + + test('returns false when there is no stored expiration', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, null)).toBe(false); + }); + + test('returns false when the access token has not expired', () => { + expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, FUTURE)).toBe(false); + }); +}); + +describe('getStoredMcpConnectionStatus', () => { + test('returns not_connected when no encrypted tokens are stored', () => { + expect(getStoredMcpConnectionStatus(null, null)).toEqual({ state: 'not_connected' }); + }); + + test('returns not_connected when tokens cannot be decrypted', () => { + decryptOAuthToken.mockReturnValueOnce(null); + + expect(getStoredMcpConnectionStatus('encrypted', null)).toEqual({ state: 'not_connected' }); + }); + + test('returns not_connected when decrypted tokens are malformed', () => { + decryptOAuthToken.mockReturnValueOnce('not json'); + + expect(getStoredMcpConnectionStatus('encrypted', null)).toEqual({ state: 'not_connected' }); + }); + + test('returns not_connected when decrypted tokens are missing required OAuth fields', () => { + expect(getStoredMcpConnectionStatus(JSON.stringify({ token_type: 'Bearer' }), null)).toEqual({ state: 'not_connected' }); + expect(getStoredMcpConnectionStatus(JSON.stringify({ access_token: 'tok' }), null)).toEqual({ state: 'not_connected' }); + }); + + test('returns not_connected when optional OAuth fields have unexpected types', () => { + expect(getStoredMcpConnectionStatus(JSON.stringify({ + access_token: 'tok', + token_type: 'Bearer', + refresh_token: true, + }), null)).toEqual({ state: 'not_connected' }); + }); + + test('returns expired when an access token has expired and cannot be refreshed', () => { + const status = getStoredMcpConnectionStatus(JSON.stringify(TOKEN_NO_REFRESH), PAST); + + expect(status).toEqual({ state: 'expired', tokens: TOKEN_NO_REFRESH }); + }); + + test('returns connected when a token can be used or refreshed', () => { + const status = getStoredMcpConnectionStatus(JSON.stringify(TOKEN_WITH_REFRESH), PAST); + + expect(status).toEqual({ state: 'connected', tokens: TOKEN_WITH_REFRESH }); + }); +}); diff --git a/packages/web/src/ee/features/mcp/connectionStatus.ts b/packages/web/src/ee/features/mcp/connectionStatus.ts new file mode 100644 index 000000000..89428b015 --- /dev/null +++ b/packages/web/src/ee/features/mcp/connectionStatus.ts @@ -0,0 +1,74 @@ +import type { OAuthTokens } from '@ai-sdk/mcp'; +import { decryptOAuthToken } from '@sourcebot/shared'; + +export type StoredMcpConnectionStatus = + | { state: 'connected'; tokens: OAuthTokens } + | { state: 'expired'; tokens: OAuthTokens } + | { state: 'not_connected' }; + +export function isTokenExpiredWithNoRefresh(tokens: OAuthTokens, tokensExpiresAt: Date | null): boolean { + if (tokens.refresh_token) { + return false; + } + if (!tokensExpiresAt) { + return false; + } + return new Date() > tokensExpiresAt; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseStoredOAuthTokens(value: string): OAuthTokens | undefined { + const parsed = JSON.parse(value); + if (!isRecord(parsed)) { + return undefined; + } + if (typeof parsed.access_token !== 'string' || typeof parsed.token_type !== 'string') { + return undefined; + } + if (parsed.refresh_token !== undefined && typeof parsed.refresh_token !== 'string') { + return undefined; + } + if (parsed.expires_in !== undefined && typeof parsed.expires_in !== 'number') { + return undefined; + } + if (parsed.scope !== undefined && typeof parsed.scope !== 'string') { + return undefined; + } + if (parsed.id_token !== undefined && typeof parsed.id_token !== 'string') { + return undefined; + } + + return parsed as OAuthTokens; +} + +export function getStoredMcpConnectionStatus( + encryptedTokens: string | null | undefined, + tokensExpiresAt: Date | null, +): StoredMcpConnectionStatus { + if (!encryptedTokens) { + return { state: 'not_connected' }; + } + + try { + const decrypted = decryptOAuthToken(encryptedTokens); + if (!decrypted) { + return { state: 'not_connected' }; + } + + const tokens = parseStoredOAuthTokens(decrypted); + if (!tokens) { + return { state: 'not_connected' }; + } + + if (isTokenExpiredWithNoRefresh(tokens, tokensExpiresAt)) { + return { state: 'expired', tokens }; + } + + return { state: 'connected', tokens }; + } catch { + return { state: 'not_connected' }; + } +} diff --git a/packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts b/packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts new file mode 100644 index 000000000..92d0fa23e --- /dev/null +++ b/packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useMemo, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { getMcpServerTools } from '@/app/api/(client)/client'; +import { isServiceError } from '@/lib/utils'; +import { mcpQueryKeys } from '@/ee/features/mcp/queryKeys'; +import type { ServerToolsEntry } from '@/ee/features/mcp/types'; + +const EMPTY_TOOL_ENTRIES: ServerToolsEntry[] = []; + +export function useMcpToolMetadata(isOAuthAvailable: boolean, connectedServerCount: number) { + const queryClient = useQueryClient(); + const lastAuthFailureInvalidatedAtRef = useRef(0); + const { + data: toolEntries = EMPTY_TOOL_ENTRIES, + isLoading: isToolsLoading, + isError: isToolsError, + refetch: refetchTools, + dataUpdatedAt: toolsDataUpdatedAt, + } = useQuery({ + queryKey: mcpQueryKeys.tools, + queryFn: async () => { + const result = await getMcpServerTools(); + if (isServiceError(result)) { + throw new Error("Failed to load connector tools"); + } + if (!Array.isArray(result)) { + throw new Error("Unexpected response from connector tools endpoint"); + } + return result; + }, + enabled: isOAuthAvailable && connectedServerCount > 0, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const toolsByServerId = useMemo(() => { + const map = new Map(); + for (const entry of toolEntries) { + map.set(entry.serverId, entry); + } + return map; + }, [toolEntries]); + + useEffect(() => { + if (toolsDataUpdatedAt === 0) { + return; + } + if (lastAuthFailureInvalidatedAtRef.current === toolsDataUpdatedAt) { + return; + } + if (!toolEntries.some((entry) => entry.status === 'error' && entry.reason === 'auth_failed')) { + return; + } + + lastAuthFailureInvalidatedAtRef.current = toolsDataUpdatedAt; + void queryClient.invalidateQueries({ queryKey: mcpQueryKeys.serversWithStatus }); + void queryClient.invalidateQueries({ queryKey: mcpQueryKeys.configuration }); + }, [queryClient, toolEntries, toolsDataUpdatedAt]); + + return { + toolEntries, + toolsByServerId, + isToolsLoading, + isToolsError, + refetchTools, + }; +} diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts index 9d8f999e6..f4350a135 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -26,13 +26,11 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ })); // Import after mocks are set up -const { isTokenExpiredWithNoRefresh, getConnectedMcpClients } = await import('./mcpClientFactory'); +const { getConnectedMcpClients } = await import('./mcpClientFactory'); // --- Helpers --- const PAST = new Date('2020-01-01'); -const FUTURE = new Date('2099-01-01'); - const TOKEN_NO_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer' }; const TOKEN_WITH_REFRESH: OAuthTokens = { access_token: 'tok', token_type: 'Bearer', refresh_token: 'ref' }; @@ -55,26 +53,6 @@ function makeUserServer(overrides: { }; } -// --- isTokenExpiredWithNoRefresh --- - -describe('isTokenExpiredWithNoRefresh', () => { - test('returns true when access token is expired and no refresh token', () => { - expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, PAST)).toBe(true); - }); - - test('returns false when refresh_token is present even if access token is expired', () => { - expect(isTokenExpiredWithNoRefresh(TOKEN_WITH_REFRESH, PAST)).toBe(false); - }); - - test('returns false when tokensExpiresAt is null', () => { - expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, null)).toBe(false); - }); - - test('returns false when access token has not yet expired', () => { - expect(isTokenExpiredWithNoRefresh(TOKEN_NO_REFRESH, FUTURE)).toBe(false); - }); -}); - // --- getConnectedMcpClients --- describe('getConnectedMcpClients', () => { diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.ts index 996969529..b74d710ec 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.ts @@ -1,9 +1,9 @@ -import { createLogger, env, decryptOAuthToken } from '@sourcebot/shared'; +import { createLogger, env } from '@sourcebot/shared'; import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvider'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { OAuthTokens } from '@ai-sdk/mcp'; import type { PrismaClient } from '@sourcebot/db'; import { getExternalMcpErrorLogFields } from './externalMcpError'; +import { getStoredMcpConnectionStatus } from './connectionStatus'; const logger = createLogger('mcp-client-factory'); @@ -15,19 +15,6 @@ export interface McpToolSet { transport: StreamableHTTPClientTransport; } -/** - * Returns true if the access token is definitely expired and there is no refresh token to fall back on. - */ -export function isTokenExpiredWithNoRefresh(tokens: OAuthTokens, tokensExpiresAt: Date | null): boolean { - if (tokens.refresh_token) { - return false; - } - if (!tokensExpiresAt) { - return false; - } - return new Date() > tokensExpiresAt; -} - /** * Creates authenticated transports for all external MCP servers the user has valid credentials for. * Skips servers with clearly expired tokens and no refresh token. @@ -69,15 +56,13 @@ export async function getConnectedMcpClients(prisma: PrismaClient, userId: strin const serverName = userServer.server.name; try { - const decrypted = decryptOAuthToken(userServer.tokens); - if (!decrypted) { + const connectionStatus = getStoredMcpConnectionStatus(userServer.tokens, userServer.tokensExpiresAt); + if (connectionStatus.state === 'not_connected') { logger.warn(`Could not decrypt tokens for MCP server ${serverName}, skipping.`); continue; } - const tokens: OAuthTokens = JSON.parse(decrypted); - - if (isTokenExpiredWithNoRefresh(tokens, userServer.tokensExpiresAt)) { + if (connectionStatus.state === 'expired') { logger.warn(`Access token for MCP server ${serverName} is expired and has no refresh token. User ${userId} needs to re-authorize.`); continue; } diff --git a/packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts b/packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts new file mode 100644 index 000000000..292b7ae7d --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { PrismaClient } from '@sourcebot/db'; + +const createMCPClient = vi.hoisted(() => vi.fn()); +const getConnectedMcpClients = vi.hoisted(() => vi.fn()); +const loggerWarn = vi.hoisted(() => vi.fn()); + +vi.mock('@ai-sdk/mcp', () => ({ + createMCPClient, +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + warn: loggerWarn, + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }), + env: { + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: 60000, + }, +})); + +vi.mock('./mcpClientFactory', () => ({ + getConnectedMcpClients, +})); + +const { getMcpToolMetadata } = await import('./mcpToolMetadata'); + +function makeConnectedClient(serverId = 'server-1') { + return { + serverId, + serverName: 'Linear', + sanitizedName: 'linear', + serverUrl: 'https://linear.example/mcp', + transport: { + close: vi.fn().mockResolvedValue(undefined), + }, + }; +} + +beforeEach(() => { + createMCPClient.mockReset(); + getConnectedMcpClients.mockReset(); + loggerWarn.mockReset(); +}); + +describe('getMcpToolMetadata', () => { + test('returns sanitized tool summaries for connected servers', async () => { + const connectedClient = makeConnectedClient(); + const mcpClient = { + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'lookup', + title: 'Lookup', + description: 'Find issues\nquickly', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + unknownHint: true, + }, + inputSchema: { type: 'object' }, + }, + ], + }), + close: vi.fn().mockResolvedValue(undefined), + }; + getConnectedMcpClients.mockResolvedValue([connectedClient]); + createMCPClient.mockResolvedValue(mcpClient); + + const result = await getMcpToolMetadata({} as PrismaClient, 'user-1', 1); + + expect(result).toEqual([ + { + status: 'available', + serverId: 'server-1', + tools: [ + { + name: 'lookup', + title: 'Lookup', + description: 'Find alert(1) issues quickly', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + ], + }, + ]); + expect(mcpClient.close).toHaveBeenCalledTimes(1); + expect(connectedClient.transport.close).toHaveBeenCalledTimes(1); + }); + + test('truncates very large tool lists', async () => { + const connectedClient = makeConnectedClient(); + const tools = Array.from({ length: 201 }, (_, index) => ({ + name: `tool-${index}`, + description: 'x'.repeat(600), + inputSchema: { type: 'object' }, + })); + const mcpClient = { + listTools: vi.fn().mockResolvedValue({ tools }), + close: vi.fn().mockResolvedValue(undefined), + }; + getConnectedMcpClients.mockResolvedValue([connectedClient]); + createMCPClient.mockResolvedValue(mcpClient); + + const result = await getMcpToolMetadata({} as PrismaClient, 'user-1', 1); + const entry = result[0]; + + expect(entry.status).toBe('available'); + if (entry.status === 'available') { + expect(entry.tools).toHaveLength(200); + expect(entry.truncated).toBe(true); + expect(entry.tools[0].description).toHaveLength(500); + } + }); + + test('does not mark the list truncated when only text fields are shortened', async () => { + const connectedClient = makeConnectedClient(); + const mcpClient = { + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'tool', + description: 'x'.repeat(600), + inputSchema: { type: 'object' }, + }, + ], + }), + close: vi.fn().mockResolvedValue(undefined), + }; + getConnectedMcpClients.mockResolvedValue([connectedClient]); + createMCPClient.mockResolvedValue(mcpClient); + + const result = await getMcpToolMetadata({} as PrismaClient, 'user-1', 1); + const entry = result[0]; + + expect(entry.status).toBe('available'); + if (entry.status === 'available') { + expect(entry.truncated).toBeUndefined(); + expect(entry.tools[0].description).toHaveLength(500); + } + }); + + test('maps safe auth failures without throwing the whole response', async () => { + const connectedClient = makeConnectedClient(); + getConnectedMcpClients.mockResolvedValue([connectedClient]); + createMCPClient.mockRejectedValue(Object.assign(new Error('unauthorized'), { statusCode: 401 })); + + const result = await getMcpToolMetadata({} as PrismaClient, 'user-1', 1); + + expect(result).toEqual([ + { + status: 'error', + serverId: 'server-1', + reason: 'auth_failed', + }, + ]); + expect(connectedClient.transport.close).toHaveBeenCalledTimes(1); + expect(loggerWarn).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/mcpToolMetadata.ts b/packages/web/src/ee/features/mcp/mcpToolMetadata.ts new file mode 100644 index 000000000..3a43298ba --- /dev/null +++ b/packages/web/src/ee/features/mcp/mcpToolMetadata.ts @@ -0,0 +1,269 @@ +import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'; +import { createLogger, env } from '@sourcebot/shared'; +import type { PrismaClient } from '@sourcebot/db'; +import { getConnectedMcpClients, type McpToolSet } from './mcpClientFactory'; +import { getExternalMcpErrorLogFields } from './externalMcpError'; +import type { + GetMcpToolsResponse, + ServerToolsEntry, + ToolMetadataErrorReason, + ToolSummary, +} from './types'; + +const logger = createLogger('mcp-tool-metadata'); + +const MCP_TOOL_METADATA_FETCH_CONCURRENCY = 4; +const MCP_TOOL_METADATA_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 10000); +const MCP_TOOL_METADATA_MAX_TOOLS = 200; +const MCP_TOOL_METADATA_MAX_NAME_LENGTH = 128; +const MCP_TOOL_METADATA_MAX_TITLE_LENGTH = 160; +const MCP_TOOL_METADATA_MAX_DESCRIPTION_LENGTH = 500; + +class ToolMetadataTimeoutError extends Error { + constructor() { + super(`MCP tool metadata fetch timed out after ${MCP_TOOL_METADATA_TIMEOUT_MS}ms`); + this.name = 'ToolMetadataTimeoutError'; + } +} + +type ListToolsResult = Awaited>; +type ToolDefinition = ListToolsResult['tools'][number]; + +function removeControlCharacters(value: string): string { + return value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' '); +} + +function removeHtmlTags(value: string): string { + return value.replace(/<[^>]*>/g, ''); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function sanitizeText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const sanitized = normalizeWhitespace(removeControlCharacters(removeHtmlTags(value))); + if (!sanitized) { + return undefined; + } + + return sanitized.length > maxLength ? sanitized.slice(0, maxLength).trimEnd() : sanitized; +} + +function sanitizeAnnotations(tool: ToolDefinition): ToolSummary['annotations'] { + const annotations = tool.annotations; + if (!annotations) { + return undefined; + } + + const sanitized: ToolSummary['annotations'] = {}; + if (typeof annotations.readOnlyHint === 'boolean') { + sanitized.readOnlyHint = annotations.readOnlyHint; + } + if (typeof annotations.destructiveHint === 'boolean') { + sanitized.destructiveHint = annotations.destructiveHint; + } + if (typeof annotations.idempotentHint === 'boolean') { + sanitized.idempotentHint = annotations.idempotentHint; + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function sanitizeTool(tool: ToolDefinition): ToolSummary { + const toolWithOptionalTitle = tool as ToolDefinition & { + title?: unknown; + annotations?: ToolDefinition['annotations'] & { title?: unknown }; + }; + const name = sanitizeText(tool.name, MCP_TOOL_METADATA_MAX_NAME_LENGTH) ?? 'unnamed_tool'; + const title = sanitizeText( + toolWithOptionalTitle.title ?? toolWithOptionalTitle.annotations?.title, + MCP_TOOL_METADATA_MAX_TITLE_LENGTH, + ); + const description = sanitizeText(tool.description, MCP_TOOL_METADATA_MAX_DESCRIPTION_LENGTH); + const annotations = sanitizeAnnotations(tool); + + return { + name, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(annotations ? { annotations } : {}), + }; +} + +async function withTimeout( + promise: Promise, + onTimeout: () => Promise, + onLateResolve?: (value: T) => Promise, +): Promise { + promise.catch(() => undefined); + + return new Promise((resolve, reject) => { + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + onTimeout().catch(() => undefined); + reject(new ToolMetadataTimeoutError()); + }, MCP_TOOL_METADATA_TIMEOUT_MS); + + promise.then( + (value) => { + if (didTimeout) { + onLateResolve?.(value).catch(() => undefined); + return; + } + clearTimeout(timeoutId); + resolve(value); + }, + (error) => { + if (didTimeout) { + return; + } + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + +function getToolMetadataErrorReason(error: unknown): ToolMetadataErrorReason { + if (error instanceof ToolMetadataTimeoutError) { + return 'timeout'; + } + + const fields = getExternalMcpErrorLogFields(error); + if ( + fields.oauthError === 'invalid_grant' || + fields.oauthError === 'invalid_client' || + fields.oauthError === 'unauthorized_client' || + fields.statusCode === 401 || + fields.statusCode === 403 + ) { + return 'auth_failed'; + } + + if ( + fields.reason === 'dynamic_client_registration_unsupported' || + fields.reason === 'unsupported_grant_type' || + fields.reason === 'unsupported_response_type' || + fields.reason === 'unsupported_code_challenge_method' || + fields.statusCode === 404 || + fields.statusCode === 405 + ) { + return 'unsupported'; + } + + const message = error instanceof Error ? error.message.toLowerCase() : ''; + if (message.includes('does not support tools') || message.includes('does not support http transport')) { + return 'unsupported'; + } + + if (fields.statusCode || fields.errorClass === 'TypeError') { + return 'connection_failed'; + } + + return 'unknown'; +} + +async function cleanupMcpClient(mcpClient: MCPClient | undefined, { transport }: McpToolSet) { + // Timeout handlers close the transport immediately to interrupt the in-flight request. + // This final cleanup may close it again; transports are expected to tolerate that. + await Promise.allSettled([ + mcpClient?.close(), + transport.close(), + ]); +} + +async function fetchToolsForClient(client: McpToolSet): Promise { + let mcpClient: MCPClient | undefined; + + try { + mcpClient = await withTimeout( + createMCPClient({ transport: client.transport }), + async () => { + await client.transport.close(); + }, + async (lateClient) => { + await lateClient.close(); + }, + ); + + const result = await withTimeout( + mcpClient.listTools(), + async () => { + await client.transport.close(); + }, + ); + + const tools = result.tools.slice(0, MCP_TOOL_METADATA_MAX_TOOLS).map(sanitizeTool); + const nextCursor = (result as ListToolsResult & { nextCursor?: unknown }).nextCursor; + const truncated = result.tools.length > MCP_TOOL_METADATA_MAX_TOOLS || typeof nextCursor === 'string'; + + return { + status: 'available', + serverId: client.serverId, + tools, + ...(truncated ? { truncated } : {}), + }; + } catch (error) { + const reason = getToolMetadataErrorReason(error); + logger.warn('Failed to load MCP tool metadata.', { + serverId: client.serverId, + sanitizedName: client.sanitizedName, + reason, + error: getExternalMcpErrorLogFields(error), + }); + + return { + status: 'error', + serverId: client.serverId, + reason, + }; + } finally { + await cleanupMcpClient(mcpClient, client); + } +} + +async function fetchToolsBatch(clients: McpToolSet[]): Promise { + const settled = await Promise.allSettled(clients.map((client) => fetchToolsForClient(client))); + return settled.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } + + // Defensive: fetchToolsForClient should catch per-server failures and resolve. + const client = clients[index]; + logger.warn('Failed to load MCP tool metadata.', { + serverId: client.serverId, + sanitizedName: client.sanitizedName, + reason: 'unknown' satisfies ToolMetadataErrorReason, + error: getExternalMcpErrorLogFields(result.reason), + }); + + return { + status: 'error', + serverId: client.serverId, + reason: 'unknown', + }; + }); +} + +export async function getMcpToolMetadata( + prisma: PrismaClient, + userId: string, + orgId: number, +): Promise { + const clients = await getConnectedMcpClients(prisma, userId, orgId); + const results: ServerToolsEntry[] = []; + + for (let index = 0; index < clients.length; index += MCP_TOOL_METADATA_FETCH_CONCURRENCY) { + const batch = clients.slice(index, index + MCP_TOOL_METADATA_FETCH_CONCURRENCY); + results.push(...await fetchToolsBatch(batch)); + } + + return results; +} diff --git a/packages/web/src/ee/features/mcp/queryKeys.test.ts b/packages/web/src/ee/features/mcp/queryKeys.test.ts index f897f486a..56caa6340 100644 --- a/packages/web/src/ee/features/mcp/queryKeys.test.ts +++ b/packages/web/src/ee/features/mcp/queryKeys.test.ts @@ -3,7 +3,7 @@ import type { QueryClient } from '@tanstack/react-query'; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from './queryKeys'; describe('invalidateMcpConfigurationQueries', () => { - test('invalidates both admin configuration and account MCP server status', async () => { + test('invalidates admin configuration, account MCP server status, and tool metadata', async () => { const queryClient = { invalidateQueries: vi.fn().mockResolvedValue(undefined), } as unknown as QueryClient; @@ -12,5 +12,6 @@ describe('invalidateMcpConfigurationQueries', () => { expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpQueryKeys.configuration }); expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpQueryKeys.serversWithStatus }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpQueryKeys.tools }); }); }); diff --git a/packages/web/src/ee/features/mcp/queryKeys.ts b/packages/web/src/ee/features/mcp/queryKeys.ts index 469c9fc04..40575dae1 100644 --- a/packages/web/src/ee/features/mcp/queryKeys.ts +++ b/packages/web/src/ee/features/mcp/queryKeys.ts @@ -3,11 +3,13 @@ import type { QueryClient } from '@tanstack/react-query'; export const mcpQueryKeys = { serversWithStatus: ['mcpServersWithStatus'] as const, configuration: ['mcpConfiguration'] as const, + tools: ['mcpTools'] as const, }; export async function invalidateMcpConfigurationQueries(queryClient: QueryClient) { await Promise.all([ queryClient.invalidateQueries({ queryKey: mcpQueryKeys.configuration }), queryClient.invalidateQueries({ queryKey: mcpQueryKeys.serversWithStatus }), + queryClient.invalidateQueries({ queryKey: mcpQueryKeys.tools }), ]); } diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/mcp/types.ts index 6ddff31e4..50c286775 100644 --- a/packages/web/src/ee/features/mcp/types.ts +++ b/packages/web/src/ee/features/mcp/types.ts @@ -15,3 +15,27 @@ export interface GetMcpConfigurationResponse { allowedMode: McpConfigurationAllowedMode; isOAuthAvailable: boolean; } + +export interface ToolSummary { + name: string; + title?: string; + description?: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + }; +} + +export type ToolMetadataErrorReason = + | 'timeout' + | 'auth_failed' + | 'connection_failed' + | 'unsupported' + | 'unknown'; + +export type ServerToolsEntry = + | { status: 'available'; serverId: string; tools: ToolSummary[]; truncated?: boolean } + | { status: 'error'; serverId: string; reason: ToolMetadataErrorReason }; + +export type GetMcpToolsResponse = ServerToolsEntry[]; diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts index 3cfd4dfeb..6e11bea3b 100644 --- a/packages/web/src/ee/features/mcp/utils.ts +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -10,6 +10,10 @@ export function sanitizeMcpServerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '_'); } +export function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + function createMcpIconDataUri(svg: string): string { return `data:image/svg+xml,${encodeURIComponent(svg)}`; } From ff6fb0fa92560db94fa2ff8f094d1848580b1249 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 27 May 2026 13:15:23 -0700 Subject: [PATCH 20/40] feat(web): redesign MCP tools list as compact clickable badges Replace the full-card tool list with a compact badge grid. Clicking a badge reveals that tool's detail panel inline; only one detail is visible at a time. Also remove the focus ring from the tools trigger button. --- .../accountAskAgent/accountAskAgentPage.tsx | 5 -- .../connectorToolDisclosure.test.tsx | 24 ++++++- .../components/connectorToolDisclosure.tsx | 69 ++++++++++++++----- 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index f8cd379f5..4b1e642d4 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -168,11 +168,6 @@ function AccountConnectedConnectorCard({
-
- {tool.description && ( -

{tool.description}

- )} -
- ); - })} + > + {displayName} + + ); + })} +
+ {activeTool && ( + + )}
)}
From 33e778578e13e422daa1de6ed0f60f56182f883a Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 27 May 2026 14:20:57 -0700 Subject: [PATCH 21/40] refactor(web): extract shared ConnectorCard component Unify the duplicated card layout between AccountConnectedConnectorCard and WorkspaceConnectorCard into a single ConnectorCard component that owns the Collapsible state, Card shell, ConnectorRowInfo, and tool disclosure wiring. The divergent parts (status badge, action buttons) are passed as ReactNode slots. --- .../accountAskAgent/accountAskAgentPage.tsx | 122 ++++++--------- .../workspaceAskAgentPage.tsx | 145 ++++++++---------- .../features/mcp/components/connectorCard.tsx | 86 +++++++++++ 3 files changed, 199 insertions(+), 154 deletions(-) create mode 100644 packages/web/src/ee/features/mcp/components/connectorCard.tsx diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index 4b1e642d4..49979fef4 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -12,7 +12,6 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Collapsible } from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; @@ -20,8 +19,9 @@ import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; +import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; -import { ConnectorToolList, ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; +import { ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; import { useConnectMcp } from "@/ee/features/mcp/hooks/useConnectMcp"; import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { disconnectMcpServer } from "@/ee/features/mcp/actions"; @@ -124,77 +124,57 @@ function AccountConnectedConnectorCard({ onDisconnect, disconnectingServerId, }: AccountConnectedConnectorCardProps) { - const [isToolListOpen, setIsToolListOpen] = useState(false); - const availableToolEntry = server.isConnected ? toolEntry : undefined; - const hasToolList = availableToolEntry?.status === 'available'; - const isLoadingToolsForServer = server.isConnected && !availableToolEntry && isToolsLoading; - return ( - setIsToolListOpen(hasToolList ? open : false)} - > - - -
- + {server.isConnected && ( + + + Connected + + )} + {server.isAuthExpired && ( + + + Authorization expired + + )} + + } + actionButtons={ + + + + + + onReconnect(server.id)}> + + Reconnect + + onDisconnect(server)} > -
- {server.isConnected && ( - - - Connected - - )} - {server.isAuthExpired && ( - - - Authorization expired - - )} - -
-
-
- - - - - - onReconnect(server.id)}> - - Reconnect - - onDisconnect(server)} - > - - {disconnectingServerId === server.id ? "Disconnecting..." : "Disconnect"} - - - -
-
- -
-
-
+ + {disconnectingServerId === server.id ? "Disconnecting..." : "Disconnect"} + + + + } + /> ); } diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 7458bd947..94363d025 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -9,7 +9,6 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Collapsible } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -22,8 +21,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; -import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; -import { ConnectorToolList, ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; +import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; import { pluralize } from "@/ee/features/mcp/utils"; @@ -80,93 +78,74 @@ function WorkspaceConnectorCard({ onCopyUrl, onDelete, }: WorkspaceConnectorCardProps) { - const [isToolListOpen, setIsToolListOpen] = useState(false); const isConnected = status?.isConnected === true; const isAuthExpired = status?.isAuthExpired === true; const isStatusUnavailable = isOAuthAvailable !== true || isStatusLoading || isStatusError || !status; - const availableToolEntry = isConnected ? toolEntry : undefined; - const hasToolList = availableToolEntry?.status === 'available'; - const isLoadingToolsForServer = isConnected && !availableToolEntry && isToolsLoading; const showConnectButton = isOAuthAvailable && !isStatusLoading && !isStatusError && !!status && !isConnected; return ( - setIsToolListOpen(hasToolList ? open : false)} - > - - -
- 0 ? "text-green-600 dark:text-green-400" : "text-muted-foreground", + )}> + 0 ? "bg-green-500/80" : "bg-muted-foreground", + )} /> + {server.savedConnectionCount > 0 + ? `${server.savedConnectionCount} ${pluralize(server.savedConnectionCount, "member")} connected` + : "No members connected"} + + } + actionButtons={ + <> + {showConnectButton && ( + -
- 0 ? "text-green-600 dark:text-green-400" : "text-muted-foreground", - )}> - 0 ? "bg-green-500/80" : "bg-muted-foreground", - )} /> - {server.savedConnectionCount > 0 - ? `${server.savedConnectionCount} ${pluralize(server.savedConnectionCount, "member")} connected` - : "No members connected"} - - -
-
-
- {showConnectButton && ( - - )} - - - - - - onCopyUrl(server.serverUrl)}> - - Copy URL - - onDelete(server)} - > - - Remove - - - -
-
- -
-
-
+ variant="outline" + className="h-8" + returnTo="/settings/workspaceAskAgent" + /> + )} + + + + + + onCopyUrl(server.serverUrl)}> + + Copy URL + + onDelete(server)} + > + + Remove + + + + + } + /> ); } diff --git a/packages/web/src/ee/features/mcp/components/connectorCard.tsx b/packages/web/src/ee/features/mcp/components/connectorCard.tsx new file mode 100644 index 000000000..1f29c00df --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorCard.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible } from '@/components/ui/collapsible'; +import { ConnectorRowInfo } from '@/ee/features/mcp/components/connectorRowInfo'; +import { ConnectorToolList, ConnectorToolTrigger } from '@/ee/features/mcp/components/connectorToolDisclosure'; +import type { ServerToolsEntry } from '@/ee/features/mcp/types'; + +interface ConnectorCardProps { + faviconUrl: string | undefined; + name: string; + serverUrl: string; + + isConnected: boolean; + isAuthExpired?: boolean; + isOAuthAvailable?: boolean; + isStatusUnavailable?: boolean; + toolEntry?: ServerToolsEntry; + isToolsLoading?: boolean; + isToolsError?: boolean; + onRetryTools?: () => void; + + statusBadge: ReactNode; + actionButtons: ReactNode; +} + +export function ConnectorCard({ + faviconUrl, + name, + serverUrl, + isConnected, + isAuthExpired, + isOAuthAvailable, + isStatusUnavailable, + toolEntry, + isToolsLoading = false, + isToolsError = false, + onRetryTools, + statusBadge, + actionButtons, +}: ConnectorCardProps) { + const [isToolListOpen, setIsToolListOpen] = useState(false); + const availableToolEntry = toolEntry?.status === 'available' ? toolEntry : undefined; + const hasToolList = !!availableToolEntry; + const isLoadingToolsForServer = isConnected && !availableToolEntry && isToolsLoading; + + return ( + setIsToolListOpen(hasToolList ? open : false)} + > + + +
+ +
+ {statusBadge} + +
+
+
+ {actionButtons} +
+
+ +
+
+
+ ); +} From 0c72b284f560aa550ccab89b7629789e773f4dd8 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 27 May 2026 16:35:11 -0700 Subject: [PATCH 22/40] Fix Ask approval turn progress state --- packages/web/src/features/chat/agent.test.ts | 266 +++++++++++++++ packages/web/src/features/chat/agent.ts | 21 +- .../chat/components/chatBox/chatBox.tsx | 14 +- .../chat/components/chatThread/chatThread.tsx | 44 ++- .../chatThread/chatThreadListItem.tsx | 55 ++-- .../chatThread/detailsCard.test.tsx | 93 ++++++ .../components/chatThread/detailsCard.tsx | 25 +- .../chatThread/signInPromptBanner.tsx | 6 +- .../chatThread/toolApprovalBanner.tsx | 33 +- packages/web/src/features/chat/utils.test.ts | 304 +++++++++++++++++- packages/web/src/features/chat/utils.ts | 59 +++- 11 files changed, 838 insertions(+), 82 deletions(-) create mode 100644 packages/web/src/features/chat/agent.test.ts create mode 100644 packages/web/src/features/chat/components/chatThread/detailsCard.test.tsx diff --git a/packages/web/src/features/chat/agent.test.ts b/packages/web/src/features/chat/agent.test.ts new file mode 100644 index 000000000..f28fe8ab5 --- /dev/null +++ b/packages/web/src/features/chat/agent.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ModelMessage } from 'ai'; +import type { SBChatMessage, SBChatMessagePart } from './types'; + +const mockLogger = vi.hoisted(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +})); + +const mockAi = vi.hoisted(() => ({ + convertToModelMessages: vi.fn(), + createUIMessageStream: vi.fn(), + latestCreateUIMessageStreamOptions: undefined as undefined | { + execute: (args: { writer: unknown }) => Promise | void; + }, + streamText: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => mockLogger, + env: { + SOURCEBOT_CHAT_FILE_MAX_CHARACTERS: 4000, + SOURCEBOT_CHAT_MAX_STEP_COUNT: 8, + SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0, + SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false', + }, + getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@localhost:5432/sourcebot', +})); + +vi.mock('server-only', () => ({})); + +vi.mock('@/ee/features/mcp/mcpClientFactory', () => ({ + getConnectedMcpClients: vi.fn(), +})); + +vi.mock('@/ee/features/mcp/mcpToolRegistry', () => ({ + buildMcpToolRegistry: vi.fn(() => []), + searchMcpTools: vi.fn(() => []), +})); + +vi.mock('@/ee/features/mcp/mcpToolSets', () => ({ + getMcpTools: vi.fn(), +})); + +vi.mock('@/features/git', () => ({ + getFileSource: vi.fn(), +})); + +vi.mock('@/features/tools', () => { + const createToolDefinition = (name: string) => ({ + name, + title: name, + description: `${name} description`, + inputSchema: {}, + isReadOnly: true, + isIdempotent: true, + execute: vi.fn(), + }); + + return { + findSymbolDefinitionsDefinition: createToolDefinition('find_symbol_definitions'), + findSymbolReferencesDefinition: createToolDefinition('find_symbol_references'), + getDiffDefinition: createToolDefinition('get_diff'), + globDefinition: createToolDefinition('glob'), + grepDefinition: createToolDefinition('grep'), + listCommitsDefinition: createToolDefinition('list_commits'), + listReposDefinition: createToolDefinition('list_repos'), + listTreeDefinition: createToolDefinition('list_tree'), + readFileDefinition: createToolDefinition('read_file'), + toVercelAITool: vi.fn((definition: { name: string }) => ({ + name: definition.name, + })), + }; +}); + +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: vi.fn(() => false), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); + +vi.mock('ai', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + convertToModelMessages: mockAi.convertToModelMessages, + createUIMessageStream: mockAi.createUIMessageStream, + streamText: mockAi.streamText, + }; +}); + +const { createMessageStream } = await import('./agent'); + +const listReposInput = { + sort: 'name', + page: 1, + perPage: 30, + direction: 'asc', +} as const; + +const dynamicApprovalRespondedPart = { + type: 'dynamic-tool', + toolName: 'mcp_linear__save_issue', + toolCallId: 'tool-call-1', + state: 'approval-responded', + input: { title: 'Issue' }, + approval: { id: 'approval-1', approved: true }, +} satisfies SBChatMessagePart; + +const staticApprovalRespondedPart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-2', + state: 'approval-responded', + input: listReposInput, + approval: { id: 'approval-2', approved: true }, +} satisfies SBChatMessagePart; + +const createUserMessage = (): SBChatMessage => ({ + id: 'user-message', + role: 'user', + parts: [ + { + type: 'text', + text: 'Create an issue', + }, + ], +}); + +const createAssistantMessage = (parts: SBChatMessagePart[]): SBChatMessage => ({ + id: 'assistant-message', + role: 'assistant', + parts, +}); + +const createFakeStreamResult = () => ({ + response: Promise.resolve(new Response()), + totalUsage: Promise.resolve({ + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }), + toUIMessageStream: vi.fn((options?: { onFinish?: () => Promise | void }) => { + void options?.onFinish?.(); + return {}; + }), +}); + +const runCreateMessageStream = async (messages: SBChatMessage[]) => { + const convertedLastTurn: ModelMessage = { + role: 'assistant', + content: 'converted-last-turn', + }; + mockAi.convertToModelMessages.mockResolvedValue([convertedLastTurn]); + mockAi.streamText.mockReturnValue(createFakeStreamResult()); + + const props = { + chatId: 'chat-id', + messages, + selectedRepos: [], + prisma: {}, + model: {}, + modelName: 'test-model', + onFinish: vi.fn(), + onError: () => 'error', + } as unknown as Parameters[0]; + + await createMessageStream(props); + + const execute = mockAi.latestCreateUIMessageStreamOptions?.execute; + if (!execute) { + throw new Error('Expected createUIMessageStream to capture execute callback.'); + } + + await execute({ + writer: { + merge: vi.fn(), + write: vi.fn(), + }, + }); + + const streamTextArgs = mockAi.streamText.mock.calls.at(-1)?.[0]; + if (!streamTextArgs || typeof streamTextArgs !== 'object' || !('messages' in streamTextArgs)) { + throw new Error('Expected streamText to be called with messages.'); + } + + return streamTextArgs.messages as ModelMessage[]; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockAi.latestCreateUIMessageStreamOptions = undefined; + mockAi.createUIMessageStream.mockImplementation((options: typeof mockAi.latestCreateUIMessageStreamOptions) => { + mockAi.latestCreateUIMessageStreamOptions = options; + return {}; + }); +}); + +describe('createMessageStream approval continuation', () => { + test.each([ + ['dynamic', dynamicApprovalRespondedPart], + ['static', staticApprovalRespondedPart], + ])('preserves the full last turn for %s approval responses', async (_kind, approvalPart) => { + const assistantMessage = createAssistantMessage([ + { + type: 'step-start', + }, + { + type: 'text', + text: 'I have everything I need. Let me now create the issue.', + }, + approvalPart, + ]); + + const streamTextMessages = await runCreateMessageStream([ + createUserMessage(), + assistantMessage, + ]); + + expect(mockAi.convertToModelMessages).toHaveBeenCalledWith( + [assistantMessage], + { ignoreIncompleteToolCalls: true } + ); + expect(streamTextMessages).toEqual([ + { + role: 'user', + content: 'Create an issue', + }, + { + role: 'assistant', + content: 'converted-last-turn', + }, + ]); + }); + + test('does not treat untagged latest approval-continuation text as a prior assistant answer', async () => { + const assistantMessage = createAssistantMessage([ + { + type: 'step-start', + }, + { + type: 'text', + text: 'I have everything I need. Let me now create the Linear issue!', + }, + dynamicApprovalRespondedPart, + ]); + + const streamTextMessages = await runCreateMessageStream([ + createUserMessage(), + assistantMessage, + ]); + + expect(streamTextMessages).not.toContainEqual({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'I have everything I need. Let me now create the Linear issue!', + }, + ], + }); + }); +}); diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 859f21428..1c730141e 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -1,5 +1,4 @@ import { SBChatMessage, SBChatMessageMetadata } from "@/features/chat/types"; -import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; import { getFileSource } from '@/features/git'; import { isServiceError } from "@/lib/utils"; import { LanguageModelV3 as AISDKLanguageModelV3 } from "@ai-sdk/provider"; @@ -21,7 +20,7 @@ import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; import { Source } from "./types"; -import { addLineNumbers, fileReferenceToString } from "./utils"; +import { addLineNumbers, fileReferenceToString, getAnswerPartFromAssistantMessage, getTurnProgressState } from "./utils"; import { createTools } from "./tools"; import { getConnectedMcpClients } from "@/ee/features/mcp/mcpClientFactory"; import { getMcpTools, McpToolsResult } from "@/ee/features/mcp/mcpToolSets"; @@ -86,8 +85,10 @@ export const createMessageStream = async ({ // Extract user messages and assistant answers. // We will use this as the context we carry between messages. + // Server requests always receive persisted messages between client streams, so evaluate them in the ready state. + const incomingTurnProgress = getTurnProgressState({ messages, status: 'ready' }); let messageHistory: ModelMessage[] = - messages.map((message): ModelMessage | undefined => { + messages.map((message, index): ModelMessage | undefined => { if (message.role === 'user') { return { role: 'user', @@ -96,7 +97,10 @@ export const createMessageStream = async ({ } if (message.role === 'assistant') { - const answerPart = getAnswerPartFromAssistantMessage(message, false); + const isLatestIncompleteAssistantMessage = + index === messages.length - 1 && + incomingTurnProgress.isTurnInProgress; + const answerPart = getAnswerPartFromAssistantMessage(message, isLatestIncompleteAssistantMessage); if (answerPart) { return { role: 'assistant', @@ -111,16 +115,17 @@ export const createMessageStream = async ({ // approved. We need to preserve the full tool call + approval so streamText can // execute the approved tool and continue. const lastMsg = messages[messages.length - 1]; - const hasApprovalResponses = lastMsg?.role === 'assistant' && - lastMsg.parts.some(p => p.type === 'dynamic-tool' && p.state === 'approval-responded'); + const hasApprovalContinuationReady = + lastMsg?.role === 'assistant' && + incomingTurnProgress.hasApprovalContinuationReady; // When continuing after tool approval, capture the prior turn's metadata // so we can aggregate token counts and response times across phases. - const priorMetadata = hasApprovalResponses + const priorMetadata = hasApprovalContinuationReady ? (lastMsg.metadata as SBChatMessageMetadata | undefined) : undefined; - if (hasApprovalResponses) { + if (hasApprovalContinuationReady) { const fullLastTurn = await convertToModelMessages( [lastMsg], { ignoreIncompleteToolCalls: true } diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 441caa220..51d9a3f45 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -34,7 +34,8 @@ interface ChatBoxProps { preferredSuggestionsBoxPlacement?: "top-start" | "bottom-start"; className?: string; isRedirecting?: boolean; - isGenerating?: boolean; + isTurnInProgress?: boolean; + isNetworkActive?: boolean; isDisabled?: boolean; languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; @@ -49,7 +50,8 @@ const ChatBoxComponent = ({ preferredSuggestionsBoxPlacement = "bottom-start", className, isRedirecting, - isGenerating, + isTurnInProgress, + isNetworkActive, isDisabled, isLoginWallEnabled, isAuthenticated, @@ -139,7 +141,7 @@ const ChatBoxComponent = ({ } } - if (isGenerating) { + if (isTurnInProgress) { return { isSubmitDisabled: true, isSubmitDisabledReason: "generating", @@ -159,7 +161,7 @@ const ChatBoxComponent = ({ isSubmitDisabledReason: undefined, } - }, [editor.children, isRedirecting, isGenerating, selectedLanguageModel]) + }, [editor.children, isRedirecting, isTurnInProgress, selectedLanguageModel]) const { requiresLogin, @@ -367,7 +369,7 @@ const ChatBoxComponent = ({ className={cn("flex flex-col justify-between gap-0.5 w-full px-3 py-2", className)} > ) : - isGenerating ? ( + isNetworkActive ? (
{ - (!isAtBottom && status === "streaming") && ( + (!isAtBottom && isNetworkActive) && (
@@ -91,7 +104,7 @@ const ToolApprovalItem = ({
- {hasInput && isExpanded && ( + {isExpanded && (
diff --git a/packages/web/src/features/chat/utils.test.ts b/packages/web/src/features/chat/utils.test.ts index e5a89c0bb..8f0a77b82 100644 --- a/packages/web/src/features/chat/utils.test.ts +++ b/packages/web/src/features/chat/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, vi } from 'vitest' -import { createUIMessage, fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils' +import { createUIMessage, fileReferenceToString, getAnswerPartFromAssistantMessage, getLastStepParts, getTurnProgressState, groupMessageIntoSteps, repairReferences } from './utils' import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants'; import { SBChatMessage, SBChatMessagePart } from './types'; @@ -10,6 +10,95 @@ vi.mock('@sourcebot/shared', () => ({ } })); +const createAssistantMessage = (parts: SBChatMessagePart[]): SBChatMessage => ({ + id: 'assistant-message', + role: 'assistant', + parts, +}); + +const createUserMessage = (): SBChatMessage => ({ + id: 'user-message', + role: 'user', + parts: [ + { + type: 'text', + text: 'Hello', + }, + ], +}); + +const dynamicApprovalRequestedPart = { + type: 'dynamic-tool', + toolName: 'mcp_linear__save_issue', + toolCallId: 'tool-call-1', + state: 'approval-requested', + input: { title: 'Issue' }, + approval: { id: 'approval-1' }, +} satisfies SBChatMessagePart; + +const dynamicApprovalRespondedPart = { + type: 'dynamic-tool', + toolName: 'mcp_linear__save_issue', + toolCallId: 'tool-call-1', + state: 'approval-responded', + input: { title: 'Issue' }, + approval: { id: 'approval-1', approved: true }, +} satisfies SBChatMessagePart; + +const listReposInput = { + sort: 'name', + page: 1, + perPage: 30, + direction: 'asc', +} as const; + +const listReposOutput = { + output: 'Done', + metadata: { + repos: [], + totalCount: 0, + }, +}; + +const staticApprovalRequestedPart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-2', + state: 'approval-requested', + input: listReposInput, + approval: { id: 'approval-2' }, +} satisfies SBChatMessagePart; + +const staticApprovalRespondedPart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-2', + state: 'approval-responded', + input: listReposInput, + approval: { id: 'approval-2', approved: true }, +} satisfies SBChatMessagePart; + +const outputAvailablePart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-3', + state: 'output-available', + input: listReposInput, + output: listReposOutput, +} satisfies SBChatMessagePart; + +const outputErrorPart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-5', + state: 'output-error', + input: listReposInput, + errorText: 'Tool failed', +} satisfies SBChatMessagePart; + +const inputAvailablePart = { + type: 'tool-list_repos', + toolCallId: 'tool-call-4', + state: 'input-available', + input: listReposInput, +} satisfies SBChatMessagePart; + test('fileReferenceToString formats file references correctly', () => { expect(fileReferenceToString({ @@ -148,7 +237,212 @@ test('groupMessageIntoSteps returns a single group when there is no step-start p ]); }); -test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while not streaming', () => { +test('getLastStepParts returns the last grouped step', () => { + const parts: SBChatMessagePart[] = [ + { + type: 'step-start', + }, + { + type: 'text', + text: 'First step', + }, + { + type: 'step-start', + }, + { + type: 'text', + text: 'Last step', + }, + ]; + + const lastStep = getLastStepParts(parts); + + expect(lastStep).toEqual([ + { + type: 'step-start', + }, + { + type: 'text', + text: 'Last step', + }, + ]); +}); + +test('getTurnProgressState treats submitted and streaming as in progress and navigation guarded', () => { + expect(getTurnProgressState({ messages: [createUserMessage()], status: 'submitted' })).toMatchObject({ + isNetworkActive: true, + isTurnInProgress: true, + shouldGuardNavigation: true, + }); + expect(getTurnProgressState({ messages: [createUserMessage()], status: 'streaming' })).toMatchObject({ + isNetworkActive: true, + isTurnInProgress: true, + shouldGuardNavigation: true, + }); +}); + +test('getTurnProgressState returns idle for no messages and latest user message when ready', () => { + expect(getTurnProgressState({ messages: [], status: 'ready' })).toMatchObject({ + isNetworkActive: false, + isTurnInProgress: false, + shouldGuardNavigation: false, + }); + expect(getTurnProgressState({ messages: [createUserMessage()], status: 'ready' })).toMatchObject({ + isNetworkActive: false, + isTurnInProgress: false, + shouldGuardNavigation: false, + }); +}); + +test('getTurnProgressState treats latest-step approval-requested as awaiting approval but not navigation guarded', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRequestedPart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'ready' })).toMatchObject({ + hasPendingToolApproval: true, + isAwaitingToolApproval: true, + isTurnInProgress: true, + shouldGuardNavigation: false, + }); +}); + +test('getTurnProgressState treats approval continuation readiness as in progress and navigation guarded', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRespondedPart, + outputAvailablePart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'ready' })).toMatchObject({ + hasApprovalContinuationReady: true, + isAwaitingToolApproval: false, + isTurnInProgress: true, + shouldGuardNavigation: true, + }); +}); + +test('getTurnProgressState treats approval-responded and output-error as continuation-ready', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRespondedPart, + outputErrorPart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'ready' })).toMatchObject({ + hasApprovalContinuationReady: true, + isTurnInProgress: true, + shouldGuardNavigation: true, + }); +}); + +test('getTurnProgressState does not treat a responded approval with non-terminal tools as continuation-ready', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRespondedPart, + inputAvailablePart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'ready' })).toMatchObject({ + hasApprovalContinuationReady: false, + isTurnInProgress: false, + shouldGuardNavigation: false, + }); +}); + +test('getTurnProgressState does not keep terminal tool states in progress', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + outputAvailablePart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'ready' })).toMatchObject({ + hasPendingToolApproval: false, + hasApprovalContinuationReady: false, + isTurnInProgress: false, + }); +}); + +test('getTurnProgressState treats error as not in progress even with pending approval', () => { + const message = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRequestedPart, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), message], status: 'error' })).toMatchObject({ + hasPendingToolApproval: true, + isTurnInProgress: false, + shouldGuardNavigation: false, + }); +}); + +test('getTurnProgressState ignores approvals in older messages and older steps', () => { + const olderMessage = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRequestedPart, + ]); + const latestMessage = createAssistantMessage([ + { + type: 'step-start', + }, + dynamicApprovalRequestedPart, + { + type: 'step-start', + }, + { + type: 'text', + text: 'Later step', + }, + ]); + + expect(getTurnProgressState({ messages: [createUserMessage(), olderMessage, createUserMessage()], status: 'ready' })).toMatchObject({ + isTurnInProgress: false, + }); + expect(getTurnProgressState({ messages: [createUserMessage(), latestMessage], status: 'ready' })).toMatchObject({ + isTurnInProgress: false, + }); +}); + +test('getTurnProgressState classifies dynamic and static tool approvals', () => { + expect(getTurnProgressState({ + messages: [createAssistantMessage([dynamicApprovalRequestedPart])], + status: 'ready', + })).toMatchObject({ + hasPendingToolApproval: true, + isAwaitingToolApproval: true, + }); + expect(getTurnProgressState({ + messages: [createAssistantMessage([staticApprovalRequestedPart])], + status: 'ready', + })).toMatchObject({ + hasPendingToolApproval: true, + isAwaitingToolApproval: true, + }); + expect(getTurnProgressState({ + messages: [createAssistantMessage([staticApprovalRespondedPart])], + status: 'ready', + })).toMatchObject({ + hasApprovalContinuationReady: true, + shouldGuardNavigation: true, + }); +}); + +test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while turn is complete', () => { const message: SBChatMessage = { role: 'assistant', parts: [ @@ -171,7 +465,7 @@ test('getAnswerPartFromAssistantMessage returns text part when it starts with AN }); }); -test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while streaming', () => { +test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while turn is in progress', () => { const message: SBChatMessage = { role: 'assistant', parts: [ @@ -194,7 +488,7 @@ test('getAnswerPartFromAssistantMessage returns text part when it starts with AN }); }); -test('getAnswerPartFromAssistantMessage returns last text part as fallback when not streaming and no ANSWER_TAG', () => { +test('getAnswerPartFromAssistantMessage returns last text part as fallback when turn is complete and no ANSWER_TAG', () => { const message: SBChatMessage = { role: 'assistant', parts: [ @@ -223,7 +517,7 @@ test('getAnswerPartFromAssistantMessage returns last text part as fallback when }); }); -test('getAnswerPartFromAssistantMessage returns undefined when streaming and no ANSWER_TAG', () => { +test('getAnswerPartFromAssistantMessage returns undefined when turn is in progress and no ANSWER_TAG', () => { const message: SBChatMessage = { role: 'assistant', parts: [ diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index cdcd1c0e0..c7f409ac7 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -1,5 +1,6 @@ import { BrowseHighlightRange, getBrowsePath } from "@/app/(app)/browse/hooks/utils"; -import { CreateUIMessage, TextUIPart, UIMessagePart } from "ai"; +import { CreateUIMessage, isToolUIPart, TextUIPart, UIMessagePart } from "ai"; +import type { ChatStatus, DynamicToolUIPart, ToolUIPart } from "ai"; import { Descendant, Editor, Point, Range, Transforms } from "slate"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX, FILE_REFERENCE_REGEX } from "./constants"; import { @@ -18,6 +19,11 @@ import { Source, } from "./types"; +export type SBChatToolPart = (ToolUIPart | DynamicToolUIPart) & SBChatMessagePart; + +export const isSBChatToolPart = (part: SBChatMessagePart): part is SBChatToolPart => { + return isToolUIPart(part); +}; export const insertMention = (editor: CustomEditor, data: MentionData, target?: Range | null) => { const mention: MentionElement = { @@ -325,6 +331,51 @@ export const groupMessageIntoSteps = (parts: SBChatMessagePart[]) => { return steps; } +export const getLastStepParts = (parts: SBChatMessagePart[]): SBChatMessagePart[] => { + return groupMessageIntoSteps(parts).at(-1) ?? parts; +} + +export const getTurnProgressState = ({ + messages, + status, +}: { + messages: SBChatMessage[]; + status: ChatStatus; +}) => { + const isNetworkActive = status === 'submitted' || status === 'streaming'; + const latestMessage = messages.at(-1); + const latestAssistantMessage = latestMessage?.role === 'assistant' ? latestMessage : undefined; + const latestStepToolParts = getLastStepParts(latestAssistantMessage?.parts ?? []) + .filter(isSBChatToolPart); + + const hasPendingToolApproval = latestStepToolParts.some( + (part) => part.state === 'approval-requested' + ); + const hasApprovalContinuationReady = + latestStepToolParts.some((part) => part.state === 'approval-responded') && + latestStepToolParts.every((part) => + part.state === 'output-available' || + part.state === 'output-error' || + part.state === 'approval-responded' + ); + + const isReady = status === 'ready'; + const isTurnInProgress = + isNetworkActive || + (isReady && (hasPendingToolApproval || hasApprovalContinuationReady)); + const isAwaitingToolApproval = isReady && hasPendingToolApproval; + const shouldGuardNavigation = isNetworkActive || (isReady && hasApprovalContinuationReady); + + return { + isNetworkActive, + hasPendingToolApproval, + hasApprovalContinuationReady, + isAwaitingToolApproval, + isTurnInProgress, + shouldGuardNavigation, + }; +} + // LLMs like to not follow instructions... this takes care of some common mistakes they tend to make. export const repairReferences = (text: string): string => { return text @@ -348,7 +399,7 @@ export const repairReferences = (text: string): string => { // Attempts to find the part of the assistant's message // that contains the answer. -export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStreaming: boolean): TextUIPart | undefined => { +export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isTurnInProgress: boolean): TextUIPart | undefined => { const lastTextPart = message.parts .findLast((part) => part.type === 'text') @@ -362,8 +413,8 @@ export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStre } // If the agent did not include the answer tag, then fallback to using the last text part. - // Only do this when we are no longer streaming since the agent may still be thinking. - if (!isStreaming && lastTextPart) { + // Only do this when the turn is complete since the agent may still be thinking or waiting. + if (!isTurnInProgress && lastTextPart) { return lastTextPart; } From 289bf5cf93954ba2a96278f7b5d1dfd0d6101508 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 27 May 2026 18:23:35 -0700 Subject: [PATCH 23/40] Add MCP connector usage counters --- .../migration.sql | 13 ++ packages/db/prisma/schema.prisma | 16 ++- .../workspaceAskAgentPage.tsx | 98 ++++++++++++-- .../ee/askmcp/configuration/route.test.ts | 127 ++++++++++++++++++ .../(server)/ee/askmcp/configuration/route.ts | 91 ++++++++++++- .../mcp/components/connectorCard.test.tsx | 93 +++++++++++++ .../features/mcp/components/connectorCard.tsx | 95 ++++++++----- .../connectorToolDisclosure.test.tsx | 61 +++++---- .../components/connectorToolDisclosure.tsx | 106 ++++++++------- .../connectorToolUsageDisclosure.test.tsx | 87 ++++++++++++ .../connectorToolUsageDisclosure.tsx | 113 ++++++++++++++++ .../src/ee/features/mcp/mcpToolSets.test.ts | 121 +++++++++++++++++ .../web/src/ee/features/mcp/mcpToolSets.ts | 53 +++++++- packages/web/src/ee/features/mcp/types.ts | 23 ++++ packages/web/src/ee/features/mcp/utils.ts | 26 ++++ 15 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql create mode 100644 packages/web/src/ee/features/mcp/components/connectorCard.test.tsx create mode 100644 packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx create mode 100644 packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx diff --git a/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql b/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql new file mode 100644 index 000000000..87b2a2200 --- /dev/null +++ b/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "McpServerToolCallCount" ( + "mcpServerId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName") +); + +-- AddForeignKey +ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b96626ecc..112abf301 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -669,7 +669,8 @@ model McpServer { org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int - userMcpServers UserMcpServer[] + userMcpServers UserMcpServer[] + toolCallCounts McpServerToolCallCount[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -678,6 +679,19 @@ model McpServer { @@unique([orgId, sanitizedName]) } +/// Lifetime tool call counters for an MCP server. +model McpServerToolCallCount { + mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade) + mcpServerId String + toolName String + count Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([mcpServerId, toolName]) +} + /// A user's personal connection to an MCP server. /// Stores per-user OAuth tokens and ephemeral auth-flow state. model UserMcpServer { diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 94363d025..25bd3a770 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -22,15 +22,16 @@ import { Skeleton } from "@/components/ui/skeleton"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; +import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { pluralize } from "@/ee/features/mcp/utils"; +import { formatCount, formatUsageSharePercent, pluralize } from "@/ee/features/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { PrefabConnectorPopover } from "./prefabConnectorPopover"; import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; -import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/mcp/types"; +import type { McpConfigurationServer, ServerToolsEntry, TopConnectorEntry } from "@/ee/features/mcp/types"; function clearCallbackParams() { const url = new URL(window.location.href); @@ -93,6 +94,7 @@ function WorkspaceConnectorCard({ isOAuthAvailable={isOAuthAvailable} isStatusUnavailable={isStatusUnavailable} toolEntry={isConnected ? toolEntry : undefined} + toolUsage={server.toolUsage} isToolsLoading={isToolsLoading} isToolsError={isToolsError} onRetryTools={onRetryTools} @@ -149,6 +151,82 @@ function WorkspaceConnectorCard({ ); } +function TopConnectorsCard({ + isLoading, + topConnectors, + grandTotalToolCalls, +}: { + isLoading: boolean; + topConnectors: TopConnectorEntry[]; + grandTotalToolCalls: number; +}) { + const displayedTopConnectors = topConnectors.slice(0, 2); + const topConnectorTotal = displayedTopConnectors[0]?.totalCalls ?? 0; + + return ( + + +

Top Connectors

+ {isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+ + + +
+ +
+ ))} +
+ ) : displayedTopConnectors.length === 0 || grandTotalToolCalls === 0 ? ( + <> +

+

no tool calls yet

+ + ) : ( + <> +
+ {displayedTopConnectors.map((connector) => { + const barWidth = topConnectorTotal > 0 + ? Math.min(100, (connector.totalCalls / topConnectorTotal) * 100) + : 0; + + return ( +
+
+ + + {connector.serverName} + + + {formatCount(connector.totalCalls)} ({formatUsageSharePercent(connector.usageSharePercent)}) + +
+
+
0 ? '2px' : undefined, + }} + /> +
+
+ ); + })} +
+

+ {formatCount(grandTotalToolCalls)} total tool calls +

+ + )} + + + ); +} + export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage }: WorkspaceAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); @@ -215,6 +293,8 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback const servers = data?.servers ?? []; const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; + const topConnectors = data?.topConnectors ?? []; + const grandTotalToolCalls = data?.grandTotalToolCalls ?? 0; const canCreateConnectors = data?.isOAuthAvailable === true; const isOAuthUnavailable = data?.isOAuthAvailable === false; const connectedServerCount = useMemo( @@ -418,7 +498,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
{/* 3-stat strip */} -
+

Allowed Connectors

@@ -443,13 +523,11 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback

- - -

Active in Last 7d

-

-

tool calls

-
-
+
{/* Allowed connectors subsection */} diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts index a89f382ef..4f1224586 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts @@ -11,6 +11,11 @@ const mocks = vi.hoisted(() => ({ userMcpServer: { groupBy: vi.fn(), }, + mcpServerToolCallCount: { + groupBy: vi.fn(), + aggregate: vi.fn(), + findMany: vi.fn(), + }, }, })); @@ -64,6 +69,36 @@ beforeEach(() => { _count: { _all: 2 }, }, ]); + mocks.unsafePrisma.mcpServerToolCallCount.groupBy.mockResolvedValue([ + { + mcpServerId: 'server-1', + _sum: { count: 8 }, + }, + { + mcpServerId: 'server-2', + _sum: { count: 2 }, + }, + ]); + mocks.unsafePrisma.mcpServerToolCallCount.aggregate.mockResolvedValue({ + _sum: { count: 10 }, + }); + mocks.unsafePrisma.mcpServerToolCallCount.findMany.mockResolvedValue([ + { + mcpServerId: 'server-1', + toolName: 'search_issues', + count: 5, + }, + { + mcpServerId: 'server-1', + toolName: 'get_issue', + count: 3, + }, + { + mcpServerId: 'server-2', + toolName: 'list_projects', + count: 2, + }, + ]); }); describe('GET /api/ee/askmcp/configuration', () => { @@ -102,20 +137,97 @@ describe('GET /api/ee/askmcp/configuration', () => { }, _count: { _all: true }, }); + expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).toHaveBeenCalledWith({ + by: ['mcpServerId'], + where: { + mcpServerId: { in: ['server-1', 'server-2'] }, + mcpServer: { orgId: 1 }, + count: { gt: 0 }, + }, + _sum: { count: true }, + orderBy: { _sum: { count: 'desc' } }, + take: 2, + }); + expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).toHaveBeenCalledWith({ + where: { + mcpServerId: { in: ['server-1', 'server-2'] }, + mcpServer: { orgId: 1 }, + count: { gt: 0 }, + }, + _sum: { count: true }, + }); + expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).toHaveBeenCalledWith({ + where: { + mcpServerId: { in: ['server-1', 'server-2'] }, + mcpServer: { orgId: 1 }, + count: { gt: 0 }, + }, + orderBy: [ + { mcpServerId: 'asc' }, + { count: 'desc' }, + ], + select: { + mcpServerId: true, + toolName: true, + count: true, + }, + }); expect(body).toMatchObject({ totalSavedConnectionCount: 2, + grandTotalToolCalls: 10, allowedMode: 'approved_only', isOAuthAvailable: true, + topConnectors: [ + { + serverId: 'server-1', + serverName: 'Linear', + totalCalls: 8, + usageSharePercent: 80, + }, + { + serverId: 'server-2', + serverName: 'Sentry', + totalCalls: 2, + usageSharePercent: 20, + }, + ], servers: [ { id: 'server-1', name: 'Linear', savedConnectionCount: 2, + toolUsage: { + totalCalls: 8, + usedToolCount: 2, + tools: [ + { + toolName: 'search_issues', + totalCalls: 5, + usageSharePercent: 62.5, + }, + { + toolName: 'get_issue', + totalCalls: 3, + usageSharePercent: 37.5, + }, + ], + }, }, { id: 'server-2', name: 'Sentry', savedConnectionCount: 0, + toolUsage: { + totalCalls: 2, + usedToolCount: 1, + tools: [ + { + toolName: 'list_projects', + totalCalls: 2, + usageSharePercent: 100, + }, + ], + }, }, ], }); @@ -139,6 +251,9 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); expect(mocks.hasEntitlement).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); }); test('rejects unauthenticated callers before checking OAuth entitlement', async () => { @@ -157,6 +272,9 @@ describe('GET /api/ee/askmcp/configuration', () => { }); expect(mocks.hasEntitlement).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); }); test('allows entitled owners to list cleanup data when OAuth is unsupported', async () => { @@ -175,6 +293,7 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(body).toMatchObject({ isOAuthAvailable: false, totalSavedConnectionCount: 2, + grandTotalToolCalls: 10, servers: [ { id: 'server-1', @@ -189,6 +308,9 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(mocks.withAuth).toHaveBeenCalled(); expect(prisma.mcpServer.findMany).toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).toHaveBeenCalled(); }); test('skips the unsafe aggregate query when there are no approved servers', async () => { @@ -204,9 +326,14 @@ describe('GET /api/ee/askmcp/configuration', () => { const body = await response.json(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); expect(body).toEqual({ servers: [], totalSavedConnectionCount: 0, + topConnectors: [], + grandTotalToolCalls: 0, allowedMode: 'approved_only', isOAuthAvailable: true, }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts index 303418a82..03f64e8a9 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -6,7 +6,7 @@ import { withAuth } from '@/middleware/withAuth'; import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; import { __unsafePrisma } from '@/prisma'; import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; -import type { GetMcpConfigurationResponse } from '@/ee/features/mcp/types'; +import type { GetMcpConfigurationResponse, McpServerToolUsageSummary } from '@/ee/features/mcp/types'; import { OrgRole } from '@sourcebot/db'; import type { NextRequest } from 'next/server'; @@ -47,18 +47,107 @@ export const GET = apiHandler(async (_request: NextRequest) => { connectionCounts.map((row) => [row.serverId, row._count._all]), ); + const toolCallCountWhere = { + mcpServerId: { in: serverIds }, + mcpServer: { orgId: org.id }, + count: { gt: 0 }, + }; + // The grouped query is capped to the top 2 for display, so keep a separate aggregate + // for the deployment-wide total used in percentages and footer labels. + const [topConnectorCounts, grandTotalToolCallsResult, toolCallCountRows] = serverIds.length === 0 + ? [[], { _sum: { count: null } }, []] + : await Promise.all([ + __unsafePrisma.mcpServerToolCallCount.groupBy({ + by: ['mcpServerId'], + where: toolCallCountWhere, + _sum: { count: true }, + orderBy: { _sum: { count: 'desc' } }, + take: 2, + }), + __unsafePrisma.mcpServerToolCallCount.aggregate({ + where: toolCallCountWhere, + _sum: { count: true }, + }), + __unsafePrisma.mcpServerToolCallCount.findMany({ + where: toolCallCountWhere, + orderBy: [ + { mcpServerId: 'asc' }, + { count: 'desc' }, + ], + select: { + mcpServerId: true, + toolName: true, + count: true, + }, + }), + ]); + const grandTotalToolCalls = grandTotalToolCallsResult._sum.count ?? 0; + const serverById = new Map(orgServers.map((server) => [server.id, server])); + const toolUsageByServerId = new Map(); + + for (const row of toolCallCountRows) { + const current = toolUsageByServerId.get(row.mcpServerId) ?? { + totalCalls: 0, + usedToolCount: 0, + tools: [], + }; + + current.totalCalls += row.count; + current.usedToolCount += 1; + current.tools.push({ + toolName: row.toolName, + totalCalls: row.count, + usageSharePercent: 0, + }); + toolUsageByServerId.set(row.mcpServerId, current); + } + + for (const usage of toolUsageByServerId.values()) { + usage.tools = usage.tools.map((tool) => ({ + ...tool, + usageSharePercent: usage.totalCalls > 0 + ? (tool.totalCalls / usage.totalCalls) * 100 + : 0, + })); + } + + const topConnectors = topConnectorCounts.flatMap((row) => { + const server = serverById.get(row.mcpServerId); + if (!server) { + return []; + } + + const totalCalls = row._sum.count ?? 0; + return [{ + serverId: server.id, + serverName: server.name, + faviconUrl: getMcpFaviconUrl(server.serverUrl, server.name), + totalCalls, + usageSharePercent: grandTotalToolCalls > 0 + ? (totalCalls / grandTotalToolCalls) * 100 + : 0, + }]; + }); + const servers = orgServers.map((server) => { const savedConnectionCount = countByServerId.get(server.id) ?? 0; return { ...server, faviconUrl: getMcpFaviconUrl(server.serverUrl, server.name), savedConnectionCount, + toolUsage: toolUsageByServerId.get(server.id) ?? { + totalCalls: 0, + usedToolCount: 0, + tools: [], + }, }; }); return { servers, totalSavedConnectionCount: servers.reduce((total, server) => total + server.savedConnectionCount, 0), + topConnectors, + grandTotalToolCalls, allowedMode: 'approved_only', isOAuthAvailable, }; diff --git a/packages/web/src/ee/features/mcp/components/connectorCard.test.tsx b/packages/web/src/ee/features/mcp/components/connectorCard.test.tsx new file mode 100644 index 000000000..6a4f754aa --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorCard.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { ConnectorCard } from './connectorCard'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; + +afterEach(() => { + cleanup(); +}); + +function availableEntry(): Extract { + return { + status: 'available', + serverId: 'server-1', + tools: [ + { name: 'search_issues', title: 'Search issues' }, + { name: 'get_issue' }, + { name: 'create_issue' }, + ], + }; +} + +function usageSummary(): McpServerToolUsageSummary { + return { + totalCalls: 6, + usedToolCount: 2, + tools: [ + { toolName: 'search_issues', totalCalls: 4, usageSharePercent: 66.666 }, + { toolName: 'get_issue', totalCalls: 2, usageSharePercent: 33.333 }, + ], + }; +} + +describe('ConnectorCard', () => { + test('shows only one expanded tools or usage panel at a time', () => { + render( + Connected} + actionButtons={null} + />, + ); + + const toolsTrigger = screen.getByRole('button', { name: /3 tools/ }); + const usageTrigger = screen.getByRole('button', { name: /6 tool calls/ }); + + expect(toolsTrigger.getAttribute('aria-controls')).toBeTruthy(); + expect(usageTrigger.getAttribute('aria-controls')).toBeTruthy(); + + fireEvent.click(toolsTrigger); + + expect(screen.getByRole('button', { name: 'Search issues' })).toBeTruthy(); + expect(document.getElementById(toolsTrigger.getAttribute('aria-controls') ?? '')).toBeTruthy(); + expect(screen.queryByText('Lifetime tool usage')).toBeNull(); + + fireEvent.click(usageTrigger); + + expect(screen.getByText('Lifetime tool usage')).toBeTruthy(); + expect(document.getElementById(usageTrigger.getAttribute('aria-controls') ?? '')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Search issues' })).toBeNull(); + + fireEvent.click(toolsTrigger); + + expect(screen.getByRole('button', { name: 'Search issues' })).toBeTruthy(); + expect(screen.queryByText('Lifetime tool usage')).toBeNull(); + }); + + test('hides usage disclosure for connectors with no tool calls', () => { + render( + Connected} + actionButtons={null} + />, + ); + + expect(screen.getByRole('button', { name: /3 tools/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /0 tool calls/ })).toBeNull(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/components/connectorCard.tsx b/packages/web/src/ee/features/mcp/components/connectorCard.tsx index 1f29c00df..349ae6511 100644 --- a/packages/web/src/ee/features/mcp/components/connectorCard.tsx +++ b/packages/web/src/ee/features/mcp/components/connectorCard.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useState, type ReactNode } from 'react'; +import { useId, useState, type ReactNode } from 'react'; import { Card, CardContent } from '@/components/ui/card'; -import { Collapsible } from '@/components/ui/collapsible'; import { ConnectorRowInfo } from '@/ee/features/mcp/components/connectorRowInfo'; import { ConnectorToolList, ConnectorToolTrigger } from '@/ee/features/mcp/components/connectorToolDisclosure'; -import type { ServerToolsEntry } from '@/ee/features/mcp/types'; +import { ConnectorToolUsageList, ConnectorToolUsageTrigger } from '@/ee/features/mcp/components/connectorToolUsageDisclosure'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; interface ConnectorCardProps { faviconUrl: string | undefined; @@ -17,6 +17,7 @@ interface ConnectorCardProps { isOAuthAvailable?: boolean; isStatusUnavailable?: boolean; toolEntry?: ServerToolsEntry; + toolUsage?: McpServerToolUsageSummary; isToolsLoading?: boolean; isToolsError?: boolean; onRetryTools?: () => void; @@ -34,53 +35,73 @@ export function ConnectorCard({ isOAuthAvailable, isStatusUnavailable, toolEntry, + toolUsage, isToolsLoading = false, isToolsError = false, onRetryTools, statusBadge, actionButtons, }: ConnectorCardProps) { - const [isToolListOpen, setIsToolListOpen] = useState(false); + const [openPanel, setOpenPanel] = useState<'tools' | 'usage' | null>(null); + const panelIdPrefix = useId(); + const toolsPanelId = `${panelIdPrefix}-tools`; + const usagePanelId = `${panelIdPrefix}-usage`; const availableToolEntry = toolEntry?.status === 'available' ? toolEntry : undefined; const hasToolList = !!availableToolEntry; + const hasToolUsage = (toolUsage?.totalCalls ?? 0) > 0; + const isToolListOpen = openPanel === 'tools'; + const isToolUsageOpen = hasToolUsage && openPanel === 'usage'; const isLoadingToolsForServer = isConnected && !availableToolEntry && isToolsLoading; return ( - setIsToolListOpen(hasToolList ? open : false)} - > - - -
- -
- {statusBadge} - + +
+ +
+ {statusBadge} + setOpenPanel(open && hasToolList ? 'tools' : null)} + onRetry={onRetryTools} + /> + {hasToolUsage && toolUsage && ( + setOpenPanel(open ? 'usage' : null)} /> -
-
-
- {actionButtons} + )}
+ +
+ {actionButtons}
- - - - +
+ + {hasToolUsage && toolUsage && ( + + )} +
+ ); } diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx index 62033ef54..bd8f624f1 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx +++ b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { Collapsible } from '@/components/ui/collapsible'; import { ConnectorToolList, ConnectorToolTrigger } from './connectorToolDisclosure'; import type { ServerToolsEntry } from '@/ee/features/mcp/types'; @@ -9,11 +8,7 @@ afterEach(() => { }); function renderToolTrigger(props: React.ComponentProps) { - return render( - - - , - ); + return render(); } function availableEntry(overrides: Partial> = {}): Extract { @@ -60,17 +55,13 @@ describe('ConnectorToolTrigger', () => { test('renders actionable labels for disconnected and expired auth states', () => { const { rerender } = render( - - - , + , ); expect(screen.getByText('Connect to see tools')).toBeTruthy(); rerender( - - - , + , ); expect(screen.getByText('Reconnect to see tools')).toBeTruthy(); @@ -79,21 +70,17 @@ describe('ConnectorToolTrigger', () => { test('renders loading and retryable error states for connected servers', () => { const onRetry = vi.fn(); const { rerender } = render( - - - , + , ); expect(screen.getByText('Loading tools...')).toBeTruthy(); rerender( - - - , + , ); expect(screen.getByText('Tools timed out')).toBeTruthy(); @@ -114,9 +101,7 @@ describe('ConnectorToolTrigger', () => { describe('ConnectorToolList', () => { test('renders compact tool badges and expands detail on click', () => { render( - - - , + , ); // Both tool badges are visible @@ -147,19 +132,33 @@ describe('ConnectorToolList', () => { test('renders an empty-tools message for available servers with no tools', () => { render( - - - , + , ); expect(screen.getByText('No tools exposed by this connector.')).toBeTruthy(); }); + test('clears selected tool detail when closed', () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + expect(screen.getByText('Search issues')).toBeTruthy(); + + rerender( + , + ); + rerender( + , + ); + + expect(screen.queryByText('Search issues')).toBeNull(); + }); + test('does not render list content for non-available entries', () => { render( - - - , + , ); expect(screen.queryByText('No tools exposed by this connector.')).toBeNull(); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx index be31634a8..562bc15c3 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx +++ b/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; -import { CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import { pluralize } from '@/ee/features/mcp/utils'; import type { ServerToolsEntry, ToolMetadataErrorReason, ToolSummary } from '@/ee/features/mcp/types'; @@ -37,6 +36,8 @@ interface ConnectorToolTriggerProps { isLoading?: boolean; isToolsQueryError?: boolean; isOpen?: boolean; + controlsId?: string; + onOpenChange?: (open: boolean) => void; onRetry?: () => void; } @@ -49,6 +50,8 @@ export function ConnectorToolTrigger({ isLoading = false, isToolsQueryError = false, isOpen = false, + controlsId, + onOpenChange, onRetry, }: ConnectorToolTriggerProps) { const availableEntry = toolEntry?.status === 'available' ? toolEntry : undefined; @@ -57,16 +60,17 @@ export function ConnectorToolTrigger({ if (canExpand) { return ( - - - + ); } @@ -155,52 +159,58 @@ function ToolDetail({ tool }: { tool: ToolSummary }) { interface ConnectorToolListProps { toolEntry?: ServerToolsEntry; + isOpen?: boolean; + id?: string; } -export function ConnectorToolList({ toolEntry }: ConnectorToolListProps) { +export function ConnectorToolList({ toolEntry, isOpen = true, id }: ConnectorToolListProps) { const [selectedTool, setSelectedTool] = useState(null); - if (toolEntry?.status !== 'available') { + useEffect(() => { + if (!isOpen) { + setSelectedTool(null); + } + }, [isOpen]); + + if (!isOpen || toolEntry?.status !== 'available') { return null; } const activeTool = toolEntry.tools.find((t) => t.name === selectedTool); return ( - -
- {toolEntry.tools.length === 0 ? ( -

No tools exposed by this connector.

- ) : ( -
-
- {toolEntry.tools.map((tool) => { - const displayName = tool.title ?? tool.name; - const isSelected = selectedTool === tool.name; - - return ( - - ); - })} -
- {activeTool && ( - - )} +
+ {toolEntry.tools.length === 0 ? ( +

No tools exposed by this connector.

+ ) : ( +
+
+ {toolEntry.tools.map((tool) => { + const displayName = tool.title ?? tool.name; + const isSelected = selectedTool === tool.name; + + return ( + + ); + })}
- )} -
- + {activeTool && ( + + )} +
+ )} +
); } diff --git a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx b/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx new file mode 100644 index 000000000..8e6793216 --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { ConnectorToolUsageList, ConnectorToolUsageTrigger } from './connectorToolUsageDisclosure'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; + +afterEach(() => { + cleanup(); +}); + +function usageSummary(overrides: Partial = {}): McpServerToolUsageSummary { + return { + totalCalls: 6, + usedToolCount: 2, + tools: [ + { toolName: 'search_issues', totalCalls: 4, usageSharePercent: 66.666 }, + { toolName: 'get_issue', totalCalls: 2, usageSharePercent: 33.333 }, + ], + ...overrides, + }; +} + +function availableEntry(): Extract { + return { + status: 'available', + serverId: 'server-1', + tools: [ + { name: 'search_issues', title: 'Search issues' }, + { name: 'get_issue' }, + { name: 'create_issue' }, + ], + }; +} + +describe('ConnectorToolUsageTrigger', () => { + test('renders total tool calls and toggles open state', () => { + const onOpenChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /6 tool calls/ })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); +}); + +describe('ConnectorToolUsageList', () => { + test('renders used tools with usage bars and footer', () => { + render( + , + ); + + expect(screen.getByText('Lifetime tool usage')).toBeTruthy(); + expect(screen.getByText('Search issues')).toBeTruthy(); + expect(screen.getByText('search_issues')).toBeTruthy(); + expect(screen.getByText('get_issue')).toBeTruthy(); + expect(screen.getByText('6 total tool calls across 2 of 3 tools')).toBeTruthy(); + }); + + test('renders empty usage state', () => { + render( + , + ); + + expect(screen.getByText('No tool calls yet.')).toBeTruthy(); + }); + + test('does not render when closed', () => { + render( + , + ); + + expect(screen.queryByText('Lifetime tool usage')).toBeNull(); + }); +}); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx b/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx new file mode 100644 index 000000000..6fc9e4f5f --- /dev/null +++ b/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { + formatCount, + formatUsageSharePercent, + pluralize, +} from '@/ee/features/mcp/utils'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; +import { BarChart3Icon, ChevronDownIcon } from 'lucide-react'; + +interface ConnectorToolUsageTriggerProps { + toolUsage: McpServerToolUsageSummary; + isOpen?: boolean; + controlsId?: string; + onOpenChange?: (open: boolean) => void; +} + +export function ConnectorToolUsageTrigger({ + toolUsage, + isOpen = false, + controlsId, + onOpenChange, +}: ConnectorToolUsageTriggerProps) { + return ( + + ); +} + +interface ConnectorToolUsageListProps { + toolUsage: McpServerToolUsageSummary; + toolEntry?: Extract; + isOpen?: boolean; + id?: string; +} + +export function ConnectorToolUsageList({ + toolUsage, + toolEntry, + isOpen = true, + id, +}: ConnectorToolUsageListProps) { + if (!isOpen) { + return null; + } + + const topToolTotal = toolUsage.tools[0]?.totalCalls ?? 0; + const toolByName = new Map(toolEntry?.tools.map((tool) => [tool.name, tool]) ?? []); + + return ( +
+

Lifetime tool usage

+ {toolUsage.totalCalls === 0 || toolUsage.tools.length === 0 ? ( +

No tool calls yet.

+ ) : ( + <> +
+ {toolUsage.tools.map((tool) => { + const toolMetadata = toolByName.get(tool.toolName); + const displayName = toolMetadata?.title ?? tool.toolName; + const barWidth = topToolTotal > 0 + ? Math.min(100, (tool.totalCalls / topToolTotal) * 100) + : 0; + + return ( +
+
+ + {displayName} + + + {formatCount(tool.totalCalls)} ({formatUsageSharePercent(tool.usageSharePercent)}) + +
+
+
0 ? '2px' : undefined, + }} + /> +
+ {toolMetadata?.title && toolMetadata.title !== tool.toolName && ( +

+ {tool.toolName} +

+ )} +
+ ); + })} +
+

+ {formatCount(toolUsage.totalCalls)} total tool calls across{' '} + {toolEntry + ? `${formatCount(toolUsage.usedToolCount)} of ${formatCount(toolEntry.tools.length)} ${pluralize(toolEntry.tools.length, 'tool')}` + : `${formatCount(toolUsage.usedToolCount)} used ${pluralize(toolUsage.usedToolCount, 'tool')}`} +

+ + )} +
+ ); +} diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts index ebbdbfacc..a00cfc466 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts @@ -1,4 +1,5 @@ import { expect, test, describe, vi, beforeEach } from 'vitest'; +import { Prisma } from '@sourcebot/db'; import type { McpToolSet } from './mcpClientFactory'; // --- Mocks --- @@ -10,6 +11,8 @@ const mockLogger = vi.hoisted(() => ({ error: vi.fn(), debug: vi.fn(), })); +const mockToolCallCountUpsert = vi.hoisted(() => vi.fn()); +const mockToolCallCountUpdate = vi.hoisted(() => vi.fn()); vi.mock('@ai-sdk/mcp', () => ({ createMCPClient: (...args: unknown[]) => mockCreateMCPClient(...args), @@ -22,6 +25,15 @@ vi.mock('@sourcebot/shared', () => ({ }, })); +vi.mock('@/prisma', () => ({ + __unsafePrisma: { + mcpServerToolCallCount: { + upsert: mockToolCallCountUpsert, + update: mockToolCallCountUpdate, + }, + }, +})); + vi.mock('ai', () => ({ jsonSchema: vi.fn((schema: unknown, opts: unknown) => ({ schema, ...(opts as object) })), })); @@ -70,6 +82,8 @@ const { getMcpTools } = await import('./mcpToolSets'); beforeEach(() => { vi.clearAllMocks(); + mockToolCallCountUpsert.mockResolvedValue({}); + mockToolCallCountUpdate.mockResolvedValue({}); }); describe('getMcpTools', () => { @@ -299,5 +313,112 @@ describe('getMcpTools', () => { await expect( tool.execute({}, { messages: [], toolCallId: 'test' }) ).rejects.toThrow('External API failed'); + expect(mockToolCallCountUpsert).not.toHaveBeenCalled(); + expect(mockToolCallCountUpdate).not.toHaveBeenCalled(); + }); + + test('tool execute wrapper increments the raw tool call counter after success', async () => { + const mockClient = createMockMcpClient([ + { name: 'create_issue', description: 'Create issue' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-linear', serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__create_issue']; + await expect( + tool.execute({ title: 'My Issue' }, { messages: [], toolCallId: 'test' }) + ).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] }); + + expect(mockToolCallCountUpsert).toHaveBeenCalledWith({ + where: { + mcpServerId_toolName: { + mcpServerId: 'server-linear', + toolName: 'create_issue', + }, + }, + create: { + mcpServerId: 'server-linear', + toolName: 'create_issue', + count: 1, + }, + update: { + count: { increment: 1 }, + }, + }); + expect(mockToolCallCountUpdate).not.toHaveBeenCalled(); + }); + + test('tool execute wrapper waits for the counter increment before resolving', async () => { + let resolveCounter: (() => void) | undefined; + mockToolCallCountUpsert.mockImplementationOnce(() => new Promise((resolve) => { + resolveCounter = resolve; + })); + + const mockClient = createMockMcpClient([ + { name: 'list_issues', description: 'List issues', annotations: { readOnlyHint: true } }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-linear', serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__list_issues']; + const execution = tool.execute({}, { messages: [], toolCallId: 'test' }); + let didResolve = false; + const observedExecution = execution.then((value) => { + didResolve = true; + return value; + }); + + await vi.waitFor(() => { + expect(mockToolCallCountUpsert).toHaveBeenCalledTimes(1); + }); + await Promise.resolve(); + + expect(resolveCounter).toBeDefined(); + expect(didResolve).toBe(false); + + resolveCounter?.(); + + await expect(observedExecution).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] }); + expect(didResolve).toBe(true); + }); + + test('tool execute wrapper retries with an atomic update after a unique conflict', async () => { + const uniqueConflict = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: '0', + }); + mockToolCallCountUpsert.mockRejectedValueOnce(uniqueConflict); + + const mockClient = createMockMcpClient([ + { name: 'create_issue', description: 'Create issue' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-linear', serverName: 'Linear' }), + ]); + + const tool = result.tools['mcp_linear__create_issue']; + await expect( + tool.execute({ title: 'My Issue' }, { messages: [], toolCallId: 'test' }) + ).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] }); + + expect(mockToolCallCountUpdate).toHaveBeenCalledWith({ + where: { + mcpServerId_toolName: { + mcpServerId: 'server-linear', + toolName: 'create_issue', + }, + }, + data: { + count: { increment: 1 }, + }, + }); }); }); diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index febae502c..1c5b6a7fc 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -6,6 +6,8 @@ import { jsonSchema, ToolExecutionOptions } from 'ai'; import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import { getExternalMcpErrorLogFields } from './externalMcpError'; import { getMcpFaviconUrl } from './utils'; +import { __unsafePrisma } from '@/prisma'; +import { Prisma } from '@sourcebot/db'; const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); @@ -17,6 +19,43 @@ class McpToolTimeoutError extends Error { } } +async function incrementMcpToolCallCounter(serverId: string, toolName: string) { + try { + await __unsafePrisma.mcpServerToolCallCount.upsert({ + where: { + mcpServerId_toolName: { + mcpServerId: serverId, + toolName, + }, + }, + create: { + mcpServerId: serverId, + toolName, + count: 1, + }, + update: { + count: { increment: 1 }, + }, + }); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') { + throw error; + } + + await __unsafePrisma.mcpServerToolCallCount.update({ + where: { + mcpServerId_toolName: { + mcpServerId: serverId, + toolName, + }, + }, + data: { + count: { increment: 1 }, + }, + }); + } +} + export interface McpToolsResult { tools: Record>[string]>; failedServers: string[]; @@ -98,10 +137,22 @@ export async function getMcpTools(clients: McpToolSet[]): Promise { + logger.warn('Failed to increment MCP tool call counter', { + serverId, + toolName: qualifiedName, + error: error instanceof Error ? error.message : String(error), + }); + }); + + return result; } catch (error) { if (timeoutSignal.aborted) { logger.warn(`MCP tool "${qualifiedName}" timed out after ${timeoutMs}ms`); diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/mcp/types.ts index 50c286775..c55539d29 100644 --- a/packages/web/src/ee/features/mcp/types.ts +++ b/packages/web/src/ee/features/mcp/types.ts @@ -5,13 +5,36 @@ export interface McpConfigurationServer { sanitizedName: string; faviconUrl: string | undefined; savedConnectionCount: number; + toolUsage: McpServerToolUsageSummary; } export type McpConfigurationAllowedMode = 'approved_only'; +export interface McpToolUsageEntry { + toolName: string; + totalCalls: number; + usageSharePercent: number; +} + +export interface McpServerToolUsageSummary { + totalCalls: number; + usedToolCount: number; + tools: McpToolUsageEntry[]; +} + +export interface TopConnectorEntry { + serverId: string; + serverName: string; + faviconUrl: string | undefined; + totalCalls: number; + usageSharePercent: number; +} + export interface GetMcpConfigurationResponse { servers: McpConfigurationServer[]; totalSavedConnectionCount: number; + topConnectors: TopConnectorEntry[]; + grandTotalToolCalls: number; allowedMode: McpConfigurationAllowedMode; isOAuthAvailable: boolean; } diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/mcp/utils.ts index 6e11bea3b..5d1453cbb 100644 --- a/packages/web/src/ee/features/mcp/utils.ts +++ b/packages/web/src/ee/features/mcp/utils.ts @@ -14,6 +14,32 @@ export function pluralize(count: number, singular: string, plural = `${singular} return count === 1 ? singular : plural; } +const standardNumberFormatter = new Intl.NumberFormat(); +const compactNumberFormatter = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatCount(count: number) { + if (count >= 10_000) { + return compactNumberFormatter.format(count); + } + return standardNumberFormatter.format(count); +} + +export function formatUsageSharePercent(percent: number) { + if (percent <= 0) { + return "0%"; + } + if (percent < 1) { + return "<1%"; + } + if (percent < 10) { + return `${percent.toFixed(1).replace(/\.0$/, "")}%`; + } + return `${Math.round(percent)}%`; +} + function createMcpIconDataUri(svg: string): string { return `data:image/svg+xml,${encodeURIComponent(svg)}`; } From 5fc2fc8aebc63821abbdc7666bbf86789b1064a8 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 11:10:18 -0700 Subject: [PATCH 24/40] Address MCP review feedback --- packages/shared/src/env.server.test.ts | 28 ++++++ packages/shared/src/env.server.ts | 3 +- .../[id]/components/chatThreadPanel.test.tsx | 89 +++++++++++++++++++ .../chat/[id]/components/chatThreadPanel.tsx | 14 ++- .../workspaceAskAgentPage.tsx | 9 +- .../(server)/ee/askmcp/callback/route.test.ts | 18 ++++ .../api/(server)/ee/askmcp/callback/route.ts | 11 +-- .../(server)/ee/askmcp/connect/route.test.ts | 40 +++++++++ .../api/(server)/ee/askmcp/connect/route.ts | 12 ++- .../chatThread/detailsCard.test.tsx | 29 ++++++ .../components/chatThread/detailsCard.tsx | 3 + .../chatThread/mcpFailedServersBanner.tsx | 1 + .../mcp/prismaOAuthClientProvider.test.ts | 89 +++++++++++++++++++ .../features/mcp/prismaOAuthClientProvider.ts | 30 +++++-- 14 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.test.tsx diff --git a/packages/shared/src/env.server.test.ts b/packages/shared/src/env.server.test.ts index bb7c7acc3..7f9bf0bca 100644 --- a/packages/shared/src/env.server.test.ts +++ b/packages/shared/src/env.server.test.ts @@ -54,3 +54,31 @@ describe('PERMISSION_SYNC_ENABLED', () => { expect(env.PERMISSION_SYNC_ENABLED).toBe('false'); }); }); + +describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + }); + + afterEach(() => { + delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + }); + + test('defaults to 60000 when not set', async () => { + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(60000); + }); + + test('accepts positive integers', async () => { + process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = '5000'; + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(5000); + }); + + test.each(['0', '-1', '1.5', '2147483648', String(Number.MAX_SAFE_INTEGER + 1)])('rejects %s', async (timeoutMs) => { + process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = timeoutMs; + + await expect(import('./env.server.js')).rejects.toThrow(); + }); +}); diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 4be0d4c32..cbb596918 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -14,6 +14,7 @@ const booleanSchema = z.enum(["true", "false"]); // coerce helps us convert them to numbers. // @see: https://zod.dev/?id=coercion-for-primitives const numberSchema = z.coerce.number(); +const maxTimerDelayMs = 2_147_483_647; const ajv = new Ajv({ validateFormats: false, @@ -282,7 +283,7 @@ const options = { */ SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(), SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100), - SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.default(60000), + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000), DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'), DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'), diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.test.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.test.tsx new file mode 100644 index 000000000..cc3391dc0 --- /dev/null +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { SET_CHAT_STATE_SESSION_STORAGE_KEY } from '@/features/chat/constants'; +import { ChatThreadPanel } from './chatThreadPanel'; + +const { chatThreadProps } = vi.hoisted(() => ({ + chatThreadProps: [] as Array<{ disabledMcpServerIds?: unknown }>, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ id: 'chat-1' }), +})); + +vi.mock('@/features/chat/components/chatThread', () => ({ + ChatThread: (props: { disabledMcpServerIds?: unknown }) => { + chatThreadProps.push(props); + return
; + }, +})); + +function createMockStorage(): Storage { + const store = new Map(); + + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; +} + +function installMockStorage(key: 'localStorage' | 'sessionStorage') { + const storage = createMockStorage(); + Object.defineProperty(window, key, { + configurable: true, + value: storage, + }); + Object.defineProperty(globalThis, key, { + configurable: true, + value: storage, + }); +} + +describe('ChatThreadPanel', () => { + beforeEach(() => { + installMockStorage('localStorage'); + installMockStorage('sessionStorage'); + chatThreadProps.length = 0; + sessionStorage.clear(); + }); + + afterEach(() => { + cleanup(); + sessionStorage.clear(); + }); + + test('defaults restored disabled MCP server ids to an empty array when missing from session storage', async () => { + sessionStorage.setItem(SET_CHAT_STATE_SESSION_STORAGE_KEY, JSON.stringify({ + inputMessage: { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + selectedSearchScopes: [], + })); + + render( + + ); + + await waitFor(() => expect(chatThreadProps.length).toBeGreaterThan(1)); + + expect(chatThreadProps.at(-1)?.disabledMcpServerIds).toEqual([]); + }); +}); diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx index 3fe802623..0a83c51d8 100644 --- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx @@ -20,6 +20,14 @@ interface ChatThreadPanelProps { chatName?: string; } +const normalizeDisabledMcpServerIds = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((id): id is string => typeof id === 'string'); +} + export const ChatThreadPanel = ({ languageModels, repos, @@ -45,7 +53,7 @@ export const ChatThreadPanel = ({ // Use the last user message to determine what repos, contexts, and MCP state we should select by default. const lastUserMessage = messages.findLast((message) => message.role === "user"); const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? []; - const defaultDisabledMcpServerIds = lastUserMessage?.metadata?.disabledMcpServerIds ?? []; + const defaultDisabledMcpServerIds = normalizeDisabledMcpServerIds(lastUserMessage?.metadata?.disabledMcpServerIds); const [selectedSearchScopes, setSelectedSearchScopes] = useState(defaultSelectedSearchScopes); const [disabledMcpServerIds, setDisabledMcpServerIds] = useState(defaultDisabledMcpServerIds); @@ -57,7 +65,7 @@ export const ChatThreadPanel = ({ try { setInputMessage(chatState.inputMessage); setSelectedSearchScopes(chatState.selectedSearchScopes); - setDisabledMcpServerIds(chatState.disabledMcpServerIds); + setDisabledMcpServerIds(normalizeDisabledMcpServerIds(chatState.disabledMcpServerIds)); } catch { console.error('Invalid chat state in session storage'); } finally { @@ -86,4 +94,4 @@ export const ChatThreadPanel = ({ />
) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 25bd3a770..1736d8e54 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -83,6 +83,7 @@ function WorkspaceConnectorCard({ const isAuthExpired = status?.isAuthExpired === true; const isStatusUnavailable = isOAuthAvailable !== true || isStatusLoading || isStatusError || !status; const showConnectButton = isOAuthAvailable && !isStatusLoading && !isStatusError && !!status && !isConnected; + const serverLabel = server.name || server.serverUrl; return ( - @@ -139,6 +145,7 @@ function WorkspaceConnectorCard({ onDelete(server)} + aria-label={`Remove ${serverLabel}`} > Remove diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index 039cb8c14..fa9246e53 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -183,4 +183,22 @@ describe('GET /api/ee/askmcp/callback', () => { expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('client-secret'); expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('refresh-token'); }); + + test('clears verifier state when callback auth throws before provider cleanup', async () => { + mocks.mcpAuth.mockRejectedValue(new Error('token exchange failed')); + + const response = await GET(createRequest()); + const location = response.headers.get('location'); + + expect(location).toContain('status=error'); + expect(mocks.unsafePrisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + codeVerifier: null, + state: null, + }, + }); + }); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index 287064381..63661d971 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -137,19 +137,14 @@ export const GET = apiHandler(async (request: NextRequest) => { orgId: userServer.server.orgId, error: getExternalMcpErrorLogFields(error), }); + return redirectToCallbackError(reconnectMessage, callbackReturnTo); + } finally { + // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. try { await provider.invalidateCredentials('verifier'); } catch (cleanupError) { logger.warn(`Failed to clear MCP OAuth verifier for user ${session.user.id}:`, cleanupError); } - return redirectToCallbackError(reconnectMessage, callbackReturnTo); - } - - // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. - try { - await provider.invalidateCredentials('verifier'); - } catch (cleanupError) { - logger.warn(`Failed to clear MCP OAuth verifier for user ${session.user.id}:`, cleanupError); } if (result === 'AUTHORIZED') { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 2c01c5e47..dfd36a45d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { NextRequest } from 'next/server'; import { McpServerClientInfoSource } from '@sourcebot/db'; +import { ErrorCode } from '@/lib/errorCodes'; const mocks = vi.hoisted(() => ({ authContext: undefined as unknown, @@ -54,6 +55,28 @@ function createRequest(body: { serverId: string; returnTo?: string } = { serverI }); } +function createMalformedJsonRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"serverId":', + }); +} + +function createTextPlainRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/connect', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'server-1', + }); +} + +function createEmptyBodyRequest() { + return new NextRequest('http://localhost/api/ee/askmcp/connect', { + method: 'POST', + }); +} + function createPrismaMock() { return { mcpServer: { @@ -89,6 +112,23 @@ beforeEach(() => { }); describe('POST /api/ee/askmcp/connect', () => { + test.each([ + ['malformed JSON', createMalformedJsonRequest], + ['text/plain body', createTextPlainRequest], + ['empty body', createEmptyBodyRequest], + ])('returns a request body validation error for %s', async (_name, createInvalidRequest) => { + const response = await POST(createInvalidRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid JSON request body.', + }); + expect(mocks.mcpAuth).not.toHaveBeenCalled(); + }); + test('upserts a nameless user row and performs DCR-capable auth under a row lock', async () => { const prisma = createPrismaMock(); const tx = createTransactionMock(); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 0e19e7396..f8950eae4 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -48,7 +48,17 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } - const body = await request.json(); + let body: unknown; + try { + body = await request.json(); + } catch { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid JSON request body.', + }); + } + const parsed = bodySchema.safeParse(body); if (!parsed.success) { return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.test.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.test.tsx index ff7ce2621..6f9c924cc 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.test.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.test.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import { DetailsCard } from './detailsCard'; +import type { SBChatMessagePart } from '../../types'; vi.mock('@/hooks/useCaptureEvent', () => ({ default: () => vi.fn(), @@ -90,4 +91,32 @@ describe('DetailsCard', () => { expect(screen.queryByText('Claude Sonnet')).toBeTruthy(); expect(screen.queryByText('41k tokens')).toBeTruthy(); }); + + test('shows terminal tool activation failures instead of a loading state', () => { + const failedActivationPart = { + type: 'tool-tool_request_activation', + toolCallId: 'tool-call-1', + state: 'output-error', + input: { tool_to_activate_name: 'mcp_linear__search_issues' }, + errorText: 'Activation failed', + } satisfies SBChatMessagePart; + + render( + + + + ); + + expect(screen.queryByText('Tool activation failed: Activation failed')).toBeTruthy(); + expect(screen.queryByText('Activating tool...')).toBeNull(); + }); }); diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index aa9edb541..cd6d8228d 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -323,6 +323,9 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { ) case 'tool-tool_request_activation': + if (part.state === 'output-error') { + return Tool activation failed: {part.errorText}; + } if (part.state !== 'output-available') { return Activating tool...; } diff --git a/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx index ea207e66a..0c37b2717 100644 --- a/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx +++ b/packages/web/src/features/chat/components/chatThread/mcpFailedServersBanner.tsx @@ -33,6 +33,7 @@ export const McpFailedServersBanner = ({ serverNames, isVisible, onClose }: McpF size="sm" onClick={onClose} className="h-6 w-6 p-0 text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-200" + aria-label="Dismiss failed connectors banner" > diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts index 5e8d77084..cf40926f4 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; import { McpServerClientInfoSource } from '@sourcebot/db'; +const mocks = vi.hoisted(() => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + vi.mock('server-only', () => ({})); vi.mock('@/prisma', () => ({ __unsafePrisma: { @@ -11,6 +20,7 @@ vi.mock('@/prisma', () => ({ vi.mock('@sourcebot/shared', () => ({ encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), decryptOAuthToken: vi.fn((text: string | null | undefined) => text?.startsWith('encrypted:') ? text.slice('encrypted:'.length) : text), + createLogger: () => mocks.logger, })); const { @@ -116,6 +126,85 @@ describe('clearMcpServerClientCredentialsForObservedClient', () => { }); }); +describe('PrismaOAuthClientProvider PKCE verifier storage', () => { + test('saveCodeVerifier encrypts the verifier before persisting it', async () => { + const prisma = createPrismaMock(); + prisma.userMcpServer.update.mockResolvedValue({ + userId: 'user-1', + serverId: 'server-1', + }); + const provider = createProvider(prisma); + + await provider.saveCodeVerifier('verifier-secret'); + + expect(prisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + codeVerifier: 'encrypted:verifier-secret', + }, + }); + }); + + test('codeVerifier decrypts the stored verifier', async () => { + const prisma = createPrismaMock(); + prisma.userMcpServer.findUnique.mockResolvedValue({ + codeVerifier: 'encrypted:verifier-secret', + tokens: null, + state: null, + }); + const provider = createProvider(prisma); + + await expect(provider.codeVerifier()).resolves.toBe('verifier-secret'); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + }); + + test('codeVerifier still accepts plaintext verifier values during migration and logs the fallback', async () => { + const prisma = createPrismaMock(); + prisma.userMcpServer.findUnique.mockResolvedValue({ + codeVerifier: 'plaintext-verifier', + tokens: null, + state: null, + }); + const provider = createProvider(prisma); + + await expect(provider.codeVerifier()).resolves.toBe('plaintext-verifier'); + expect(mocks.logger.warn).toHaveBeenCalledWith( + 'MCP OAuth code verifier was read without decryption; it may be plaintext from an older version.', + { + serverId: 'server-1', + orgId: 1, + userId: 'user-1', + }, + ); + }); +}); + +describe('PrismaOAuthClientProvider authorization redirect', () => { + test('overwrites existing prompt values with consent', async () => { + const prisma = createPrismaMock(); + prisma.userMcpServer.update.mockResolvedValue({ + userId: 'user-1', + serverId: 'server-1', + }); + const provider = createProvider(prisma); + + await provider.redirectToAuthorization(new URL('https://oauth.example.com/authorize?prompt=none&client_id=client-1')); + + expect(provider.authorizationUrl).toBe('https://oauth.example.com/authorize?prompt=consent&client_id=client-1'); + expect(prisma.userMcpServer.update).toHaveBeenCalledWith({ + where: { + userId_serverId: { userId: 'user-1', serverId: 'server-1' }, + }, + data: { + tokens: null, + tokensExpiresAt: null, + }, + }); + }); +}); + describe('PrismaOAuthClientProvider static client information', () => { test('clientInformation returns static OAuth client credentials', async () => { const prisma = createPrismaMock(); diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index 3f5446b40..ca1b46508 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -6,11 +6,12 @@ import type { OAuthTokens, } from '@ai-sdk/mcp'; import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; -import { encryptOAuthToken, decryptOAuthToken } from '@sourcebot/shared'; +import { encryptOAuthToken, decryptOAuthToken, createLogger } from '@sourcebot/shared'; import { __unsafePrisma } from '@/prisma'; import { createMcpOAuthState } from './mcpOAuthReturnTo'; type McpOAuthPrismaClient = Pick; +const logger = createLogger('mcp-oauth-client-provider'); interface PrismaOAuthClientProviderOptions { prisma: McpOAuthPrismaClient; @@ -194,11 +195,30 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { if (!userServer?.codeVerifier) { throw new Error('No code verifier found'); } - return userServer.codeVerifier; + + const decrypted = decryptOAuthToken(userServer.codeVerifier); + if (!decrypted) { + throw new Error('Failed to decrypt OAuth code verifier'); + } + + if (decrypted === userServer.codeVerifier) { + logger.warn('MCP OAuth code verifier was read without decryption; it may be plaintext from an older version.', { + serverId: this.serverId, + orgId: this.orgId, + userId: this.userId, + }); + } + + return decrypted; } async saveCodeVerifier(codeVerifier: string): Promise { - await this.updateUserServer({ codeVerifier }); + const encrypted = encryptOAuthToken(codeVerifier); + if (!encrypted) { + throw new Error('Failed to encrypt OAuth code verifier'); + } + + await this.updateUserServer({ codeVerifier: encrypted }); } async state(): Promise { @@ -219,9 +239,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { // This prevents a stolen-session attack where an attacker signs into Sourcebot on // a victim's machine and silently obtains the victim's provider tokens via an // existing browser session. - if (!url.searchParams.has('prompt')) { - url.searchParams.set('prompt', 'consent'); - } + url.searchParams.set('prompt', 'consent'); // Clear stale tokens before starting a new authorization flow so the UI reflects // that the user needs to complete OAuth again. From 25dae4ee0e6b96c95699e30b64d026a54f6f7364 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 12:17:50 -0700 Subject: [PATCH 25/40] Remove workspace Ask Agent connector summary cards --- .../workspaceAskAgentPage.tsx | 117 +----------------- .../ee/askmcp/configuration/route.test.ts | 67 +--------- .../(server)/ee/askmcp/configuration/route.ts | 66 +++------- packages/web/src/ee/features/mcp/types.ts | 11 -- 4 files changed, 18 insertions(+), 243 deletions(-) diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 1736d8e54..1cba08e1f 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -22,16 +22,15 @@ import { Skeleton } from "@/components/ui/skeleton"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { formatCount, formatUsageSharePercent, pluralize } from "@/ee/features/mcp/utils"; +import { pluralize } from "@/ee/features/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { PrefabConnectorPopover } from "./prefabConnectorPopover"; import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; -import type { McpConfigurationServer, ServerToolsEntry, TopConnectorEntry } from "@/ee/features/mcp/types"; +import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/mcp/types"; function clearCallbackParams() { const url = new URL(window.location.href); @@ -158,82 +157,6 @@ function WorkspaceConnectorCard({ ); } -function TopConnectorsCard({ - isLoading, - topConnectors, - grandTotalToolCalls, -}: { - isLoading: boolean; - topConnectors: TopConnectorEntry[]; - grandTotalToolCalls: number; -}) { - const displayedTopConnectors = topConnectors.slice(0, 2); - const topConnectorTotal = displayedTopConnectors[0]?.totalCalls ?? 0; - - return ( - - -

Top Connectors

- {isLoading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( -
-
- - - -
- -
- ))} -
- ) : displayedTopConnectors.length === 0 || grandTotalToolCalls === 0 ? ( - <> -

-

no tool calls yet

- - ) : ( - <> -
- {displayedTopConnectors.map((connector) => { - const barWidth = topConnectorTotal > 0 - ? Math.min(100, (connector.totalCalls / topConnectorTotal) * 100) - : 0; - - return ( -
-
- - - {connector.serverName} - - - {formatCount(connector.totalCalls)} ({formatUsageSharePercent(connector.usageSharePercent)}) - -
-
-
0 ? '2px' : undefined, - }} - /> -
-
- ); - })} -
-

- {formatCount(grandTotalToolCalls)} total tool calls -

- - )} - - - ); -} - export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage }: WorkspaceAskAgentPageProps) { const { toast } = useToast(); const queryClient = useQueryClient(); @@ -299,9 +222,6 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback }, [serversWithStatus]); const servers = data?.servers ?? []; - const totalSavedConnectionCount = data?.totalSavedConnectionCount ?? 0; - const topConnectors = data?.topConnectors ?? []; - const grandTotalToolCalls = data?.grandTotalToolCalls ?? 0; const canCreateConnectors = data?.isOAuthAvailable === true; const isOAuthUnavailable = data?.isOAuthAvailable === false; const connectedServerCount = useMemo( @@ -504,39 +424,6 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback

- {/* 3-stat strip */} -
- - -

Allowed Connectors

- {isLoading ? ( - - ) : ( -

{servers.length}

- )} -

approved for workspace

-
-
- - -

Saved Connections

- {isLoading ? ( - - ) : ( -

{totalSavedConnectionCount}

- )} -

- {totalSavedConnectionCount === 1 ? "member has" : "members have"} credentials saved -

-
-
- -
- {/* Allowed connectors subsection */}
diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts index 4f1224586..b134252a0 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts @@ -12,8 +12,6 @@ const mocks = vi.hoisted(() => ({ groupBy: vi.fn(), }, mcpServerToolCallCount: { - groupBy: vi.fn(), - aggregate: vi.fn(), findMany: vi.fn(), }, }, @@ -69,19 +67,6 @@ beforeEach(() => { _count: { _all: 2 }, }, ]); - mocks.unsafePrisma.mcpServerToolCallCount.groupBy.mockResolvedValue([ - { - mcpServerId: 'server-1', - _sum: { count: 8 }, - }, - { - mcpServerId: 'server-2', - _sum: { count: 2 }, - }, - ]); - mocks.unsafePrisma.mcpServerToolCallCount.aggregate.mockResolvedValue({ - _sum: { count: 10 }, - }); mocks.unsafePrisma.mcpServerToolCallCount.findMany.mockResolvedValue([ { mcpServerId: 'server-1', @@ -137,25 +122,6 @@ describe('GET /api/ee/askmcp/configuration', () => { }, _count: { _all: true }, }); - expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).toHaveBeenCalledWith({ - by: ['mcpServerId'], - where: { - mcpServerId: { in: ['server-1', 'server-2'] }, - mcpServer: { orgId: 1 }, - count: { gt: 0 }, - }, - _sum: { count: true }, - orderBy: { _sum: { count: 'desc' } }, - take: 2, - }); - expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).toHaveBeenCalledWith({ - where: { - mcpServerId: { in: ['server-1', 'server-2'] }, - mcpServer: { orgId: 1 }, - count: { gt: 0 }, - }, - _sum: { count: true }, - }); expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).toHaveBeenCalledWith({ where: { mcpServerId: { in: ['server-1', 'server-2'] }, @@ -173,24 +139,8 @@ describe('GET /api/ee/askmcp/configuration', () => { }, }); expect(body).toMatchObject({ - totalSavedConnectionCount: 2, - grandTotalToolCalls: 10, allowedMode: 'approved_only', isOAuthAvailable: true, - topConnectors: [ - { - serverId: 'server-1', - serverName: 'Linear', - totalCalls: 8, - usageSharePercent: 80, - }, - { - serverId: 'server-2', - serverName: 'Sentry', - totalCalls: 2, - usageSharePercent: 20, - }, - ], servers: [ { id: 'server-1', @@ -233,7 +183,7 @@ describe('GET /api/ee/askmcp/configuration', () => { }); }); - test('rejects non-owners before the unsafe aggregate query', async () => { + test('rejects non-owners before unsafe connector queries', async () => { const prisma = createPrismaMock(); mocks.authContext = { org: { id: 1 }, @@ -251,8 +201,6 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); expect(mocks.hasEntitlement).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); }); @@ -272,8 +220,6 @@ describe('GET /api/ee/askmcp/configuration', () => { }); expect(mocks.hasEntitlement).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); }); @@ -292,8 +238,6 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(response.status).toBe(200); expect(body).toMatchObject({ isOAuthAvailable: false, - totalSavedConnectionCount: 2, - grandTotalToolCalls: 10, servers: [ { id: 'server-1', @@ -308,12 +252,10 @@ describe('GET /api/ee/askmcp/configuration', () => { expect(mocks.withAuth).toHaveBeenCalled(); expect(prisma.mcpServer.findMany).toHaveBeenCalled(); expect(mocks.unsafePrisma.userMcpServer.groupBy).toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).toHaveBeenCalled(); expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).toHaveBeenCalled(); }); - test('skips the unsafe aggregate query when there are no approved servers', async () => { + test('skips unsafe connector queries when there are no approved servers', async () => { const prisma = createPrismaMock(); prisma.mcpServer.findMany.mockResolvedValue([]); mocks.authContext = { @@ -326,14 +268,9 @@ describe('GET /api/ee/askmcp/configuration', () => { const body = await response.json(); expect(mocks.unsafePrisma.userMcpServer.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.groupBy).not.toHaveBeenCalled(); - expect(mocks.unsafePrisma.mcpServerToolCallCount.aggregate).not.toHaveBeenCalled(); expect(mocks.unsafePrisma.mcpServerToolCallCount.findMany).not.toHaveBeenCalled(); expect(body).toEqual({ servers: [], - totalSavedConnectionCount: 0, - topConnectors: [], - grandTotalToolCalls: 0, allowedMode: 'approved_only', isOAuthAvailable: true, }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts index 03f64e8a9..49611f2e0 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -52,37 +52,20 @@ export const GET = apiHandler(async (_request: NextRequest) => { mcpServer: { orgId: org.id }, count: { gt: 0 }, }; - // The grouped query is capped to the top 2 for display, so keep a separate aggregate - // for the deployment-wide total used in percentages and footer labels. - const [topConnectorCounts, grandTotalToolCallsResult, toolCallCountRows] = serverIds.length === 0 - ? [[], { _sum: { count: null } }, []] - : await Promise.all([ - __unsafePrisma.mcpServerToolCallCount.groupBy({ - by: ['mcpServerId'], - where: toolCallCountWhere, - _sum: { count: true }, - orderBy: { _sum: { count: 'desc' } }, - take: 2, - }), - __unsafePrisma.mcpServerToolCallCount.aggregate({ - where: toolCallCountWhere, - _sum: { count: true }, - }), - __unsafePrisma.mcpServerToolCallCount.findMany({ - where: toolCallCountWhere, - orderBy: [ - { mcpServerId: 'asc' }, - { count: 'desc' }, - ], - select: { - mcpServerId: true, - toolName: true, - count: true, - }, - }), - ]); - const grandTotalToolCalls = grandTotalToolCallsResult._sum.count ?? 0; - const serverById = new Map(orgServers.map((server) => [server.id, server])); + const toolCallCountRows = serverIds.length === 0 + ? [] + : await __unsafePrisma.mcpServerToolCallCount.findMany({ + where: toolCallCountWhere, + orderBy: [ + { mcpServerId: 'asc' }, + { count: 'desc' }, + ], + select: { + mcpServerId: true, + toolName: true, + count: true, + }, + }); const toolUsageByServerId = new Map(); for (const row of toolCallCountRows) { @@ -111,24 +94,6 @@ export const GET = apiHandler(async (_request: NextRequest) => { })); } - const topConnectors = topConnectorCounts.flatMap((row) => { - const server = serverById.get(row.mcpServerId); - if (!server) { - return []; - } - - const totalCalls = row._sum.count ?? 0; - return [{ - serverId: server.id, - serverName: server.name, - faviconUrl: getMcpFaviconUrl(server.serverUrl, server.name), - totalCalls, - usageSharePercent: grandTotalToolCalls > 0 - ? (totalCalls / grandTotalToolCalls) * 100 - : 0, - }]; - }); - const servers = orgServers.map((server) => { const savedConnectionCount = countByServerId.get(server.id) ?? 0; return { @@ -145,9 +110,6 @@ export const GET = apiHandler(async (_request: NextRequest) => { return { servers, - totalSavedConnectionCount: servers.reduce((total, server) => total + server.savedConnectionCount, 0), - topConnectors, - grandTotalToolCalls, allowedMode: 'approved_only', isOAuthAvailable, }; diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/mcp/types.ts index c55539d29..0d1c099ae 100644 --- a/packages/web/src/ee/features/mcp/types.ts +++ b/packages/web/src/ee/features/mcp/types.ts @@ -22,19 +22,8 @@ export interface McpServerToolUsageSummary { tools: McpToolUsageEntry[]; } -export interface TopConnectorEntry { - serverId: string; - serverName: string; - faviconUrl: string | undefined; - totalCalls: number; - usageSharePercent: number; -} - export interface GetMcpConfigurationResponse { servers: McpConfigurationServer[]; - totalSavedConnectionCount: number; - topConnectors: TopConnectorEntry[]; - grandTotalToolCalls: number; allowedMode: McpConfigurationAllowedMode; isOAuthAvailable: boolean; } From f358378359a6e7724f8aa5a022b025c0c2525b36 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 14:23:59 -0700 Subject: [PATCH 26/40] Add PostHog prefab MCP server --- .../web/src/ee/features/mcp/prefabMcpServers.test.ts | 9 +++++++-- packages/web/src/ee/features/mcp/prefabMcpServers.ts | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts index 18abdb0a1..7ac0face8 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts @@ -18,6 +18,11 @@ describe('prefab MCP servers', () => { name: 'Linear', serverUrl: 'https://mcp.linear.app/mcp', }, + { + id: 'posthog', + name: 'PostHog', + serverUrl: 'https://mcp.posthog.com/mcp', + }, { id: 'slack', name: 'Slack', @@ -35,13 +40,13 @@ describe('prefab MCP servers', () => { test('hides already configured prefab servers after URL normalization', () => { const availableServers = getAvailablePrefabMcpServers(['https://mcp.slack.com/mcp/']); - expect(availableServers.map((server) => server.id)).toEqual(['atlassian', 'linear']); + expect(availableServers.map((server) => server.id)).toEqual(['atlassian', 'linear', 'posthog']); }); test('hides the Atlassian prefab entry when the shared endpoint is configured', () => { const availableServers = getAvailablePrefabMcpServers(['https://mcp.atlassian.com/v1/mcp/authv2/']); - expect(availableServers.map((server) => server.id)).toEqual(['linear', 'slack']); + expect(availableServers.map((server) => server.id)).toEqual(['linear', 'posthog', 'slack']); }); test('normalizes server URLs for duplicate comparisons', () => { diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.ts b/packages/web/src/ee/features/mcp/prefabMcpServers.ts index 22b60bd16..8c11e195e 100644 --- a/packages/web/src/ee/features/mcp/prefabMcpServers.ts +++ b/packages/web/src/ee/features/mcp/prefabMcpServers.ts @@ -15,6 +15,11 @@ const prefabMcpServers = [ name: "Linear", serverUrl: "https://mcp.linear.app/mcp", }, + { + id: "posthog", + name: "PostHog", + serverUrl: "https://mcp.posthog.com/mcp", + }, { id: "slack", name: "Slack", From 6d336f735d36de11c1d1d990cd467fe4bdbdb7fa Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 15:12:19 -0700 Subject: [PATCH 27/40] Add Ask MCP PostHog metrics --- .../web/src/app/api/(server)/chat/route.ts | 19 +++ .../features/chat/askMcpAnalytics.server.ts | 136 ++++++++++++++++++ packages/web/src/features/mcp/askCodebase.ts | 4 + packages/web/src/lib/posthogEvents.ts | 19 ++- 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/features/chat/askMcpAnalytics.server.ts diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index 5953cbe0d..c3ee79f4b 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -1,4 +1,5 @@ import { sew } from "@/middleware/sew"; +import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/features/chat/askMcpAnalytics.server"; import { createMessageStream } from "@/features/chat/agent"; import { additionalChatRequestParamsSchema } from "@/features/chat/types"; import { getLanguageModelKey } from "@/features/chat/utils"; @@ -92,12 +93,19 @@ export const POST = apiHandler(async (req: NextRequest) => { }))).flat(); const source = req.headers.get('X-Sourcebot-Client-Source') ?? undefined; + const askMcpAvailability = await getAskMcpAvailabilityAnalytics({ + prisma, + userId: user?.id, + orgId: org.id, + disabledMcpServerIds, + }); await captureEvent('ask_message_sent', { chatId: id, messageCount: messages.length, selectedReposCount: expandedRepos.length, source, + ...askMcpAvailability, ...(env.EXPERIMENT_ASK_GH_ENABLED === 'true' ? { selectedRepos: expandedRepos } : {}), }); @@ -118,6 +126,17 @@ export const POST = apiHandler(async (req: NextRequest) => { orgId: org.id, onFinish: async ({ messages }) => { await updateChatMessages({ chatId: id, messages, prisma }); + const askMcpTurnCompleted = getAskMcpTurnCompletedAnalytics({ + messages, + availability: askMcpAvailability, + }); + if (askMcpTurnCompleted) { + await captureEvent('ask_mcp_turn_completed', { + chatId: id, + source, + ...askMcpTurnCompleted, + }); + } }, onError: (error: unknown) => { logger.error(error); diff --git a/packages/web/src/features/chat/askMcpAnalytics.server.ts b/packages/web/src/features/chat/askMcpAnalytics.server.ts new file mode 100644 index 000000000..c799b4908 --- /dev/null +++ b/packages/web/src/features/chat/askMcpAnalytics.server.ts @@ -0,0 +1,136 @@ +import { getStoredMcpConnectionStatus } from "@/ee/features/mcp/connectionStatus"; +import { hasEntitlement } from "@/lib/entitlements"; +import type { PrismaClient } from "@sourcebot/db"; +import type { DynamicToolUIPart } from "ai"; +import type { SBChatMessage, SBChatMessagePart } from "./types"; +import { getTurnProgressState } from "./utils"; + +export type AskMcpAvailabilityAnalytics = { + hasAskMcpServersAvailable: boolean; + askMcpConnectedServerCount: number; + askMcpEnabledServerCount: number; + askMcpDisabledServerCount: number; +}; + +export type AskMcpTurnCompletedAnalytics = { + traceId?: string; + askMcpUsed: boolean; + askMcpToolCallCount: number; + askMcpToolSuccessCount: number; + askMcpToolFailureCount: number; + askMcpApprovalRequestedCount: number; + askMcpApprovalDeniedCount: number; + askMcpFailedServerCount: number; + durationMs: number; +}; + +const emptyAskMcpAvailability: AskMcpAvailabilityAnalytics = { + hasAskMcpServersAvailable: false, + askMcpConnectedServerCount: 0, + askMcpEnabledServerCount: 0, + askMcpDisabledServerCount: 0, +}; + +type AskMcpAvailabilityPrismaClient = Pick; + +export async function getAskMcpAvailabilityAnalytics({ + prisma, + userId, + orgId, + disabledMcpServerIds, +}: { + prisma: AskMcpAvailabilityPrismaClient; + userId: string | undefined; + orgId: number; + disabledMcpServerIds: string[]; +}): Promise { + if (!userId || !(await hasEntitlement("oauth"))) { + return emptyAskMcpAvailability; + } + + const userServers = await prisma.userMcpServer.findMany({ + where: { + userId, + tokens: { not: null }, + server: { + orgId, + clientInfo: { not: null }, + }, + }, + select: { + serverId: true, + tokens: true, + tokensExpiresAt: true, + }, + }); + + const connectedServerIds = userServers + .filter((userServer) => + getStoredMcpConnectionStatus(userServer.tokens, userServer.tokensExpiresAt).state === "connected" + ) + .map((userServer) => userServer.serverId); + const disabledServerIds = new Set(disabledMcpServerIds); + const askMcpDisabledServerCount = connectedServerIds.filter((serverId) => disabledServerIds.has(serverId)).length; + const askMcpEnabledServerCount = connectedServerIds.length - askMcpDisabledServerCount; + + return { + hasAskMcpServersAvailable: askMcpEnabledServerCount > 0, + askMcpConnectedServerCount: connectedServerIds.length, + askMcpEnabledServerCount, + askMcpDisabledServerCount, + }; +} + +function isExternalMcpToolPart(part: SBChatMessagePart): part is SBChatMessagePart & DynamicToolUIPart { + return part.type === "dynamic-tool" && part.toolName.startsWith("mcp_"); +} + +function hasApproval(part: DynamicToolUIPart) { + return part.approval !== undefined; +} + +export function getAskMcpTurnCompletedAnalytics({ + messages, + availability, +}: { + messages: SBChatMessage[]; + availability: AskMcpAvailabilityAnalytics; +}): AskMcpTurnCompletedAnalytics | undefined { + const latestMessage = messages.at(-1); + const latestAssistantMessage = latestMessage?.role === "assistant" ? latestMessage : undefined; + if (!latestAssistantMessage) { + return undefined; + } + + const progressState = getTurnProgressState({ messages, status: "ready" }); + if (progressState.isTurnInProgress) { + return undefined; + } + + const externalMcpToolParts = latestAssistantMessage.parts.filter(isExternalMcpToolPart); + const askMcpToolSuccessCount = externalMcpToolParts.filter((part) => part.state === "output-available").length; + const askMcpToolFailureCount = externalMcpToolParts.filter((part) => part.state === "output-error").length; + const askMcpToolCallCount = askMcpToolSuccessCount + askMcpToolFailureCount; + const askMcpApprovalRequestedCount = externalMcpToolParts.filter(hasApproval).length; + const askMcpApprovalDeniedCount = externalMcpToolParts.filter((part) => part.state === "output-denied").length; + const askMcpFailedServerCount = latestAssistantMessage.parts.filter((part) => + part.type === "data-mcp-failed-server" + ).length; + + const hasMcpTurnActivity = externalMcpToolParts.length > 0 || askMcpFailedServerCount > 0; + if (!availability.hasAskMcpServersAvailable && !hasMcpTurnActivity) { + return undefined; + } + + return { + traceId: latestAssistantMessage.metadata?.traceId, + askMcpUsed: askMcpToolCallCount > 0, + askMcpToolCallCount, + askMcpToolSuccessCount, + askMcpToolFailureCount, + askMcpApprovalRequestedCount, + askMcpApprovalDeniedCount, + askMcpFailedServerCount, + durationMs: latestAssistantMessage.metadata?.totalResponseTimeMs ?? 0, + }; +} diff --git a/packages/web/src/features/mcp/askCodebase.ts b/packages/web/src/features/mcp/askCodebase.ts index 2c8186b96..94bf4a3f1 100644 --- a/packages/web/src/features/mcp/askCodebase.ts +++ b/packages/web/src/features/mcp/askCodebase.ts @@ -143,6 +143,10 @@ export const askCodebase = (params: AskCodebaseParams): Promise r.value) } : {}), diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index b63014002..5bbdf28f3 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -178,6 +178,10 @@ export type PosthogEventMap = { messageCount: number, selectedReposCount: number, source?: string, + hasAskMcpServersAvailable: boolean, + askMcpConnectedServerCount: number, + askMcpEnabledServerCount: number, + askMcpDisabledServerCount: number, /** * @note this field will only be populated when * the EXPERIMENT_ASK_GH_ENABLED environment variable @@ -185,6 +189,19 @@ export type PosthogEventMap = { */ selectedRepos?: string[], }, + ask_mcp_turn_completed: { + chatId: string, + source?: string, + traceId?: string, + askMcpUsed: boolean, + askMcpToolCallCount: number, + askMcpToolSuccessCount: number, + askMcpToolFailureCount: number, + askMcpApprovalRequestedCount: number, + askMcpApprovalDeniedCount: number, + askMcpFailedServerCount: number, + durationMs: number, + }, tool_used: { toolName: string, source: string, @@ -316,4 +333,4 @@ export type PosthogEventMap = { clientName: string, }, } -export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file +export type PosthogEvent = keyof PosthogEventMap; From 0bebd3b53511148c5dfbfd6f87dab63556b2fe9b Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 15:45:32 -0700 Subject: [PATCH 28/40] Add Ask MCP tool call analytics --- .../src/ee/features/mcp/mcpToolSets.test.ts | 47 ++++++++++++++++ .../web/src/ee/features/mcp/mcpToolSets.ts | 54 ++++++++++++++++++- packages/web/src/features/chat/agent.ts | 8 ++- packages/web/src/lib/posthogEvents.ts | 13 +++++ 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts index a00cfc466..6205ca844 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.test.ts @@ -13,6 +13,7 @@ const mockLogger = vi.hoisted(() => ({ })); const mockToolCallCountUpsert = vi.hoisted(() => vi.fn()); const mockToolCallCountUpdate = vi.hoisted(() => vi.fn()); +const mockCaptureEvent = vi.hoisted(() => vi.fn()); vi.mock('@ai-sdk/mcp', () => ({ createMCPClient: (...args: unknown[]) => mockCreateMCPClient(...args), @@ -34,6 +35,10 @@ vi.mock('@/prisma', () => ({ }, })); +vi.mock('@/lib/posthog', () => ({ + captureEvent: mockCaptureEvent, +})); + vi.mock('ai', () => ({ jsonSchema: vi.fn((schema: unknown, opts: unknown) => ({ schema, ...(opts as object) })), })); @@ -84,6 +89,7 @@ beforeEach(() => { vi.clearAllMocks(); mockToolCallCountUpsert.mockResolvedValue({}); mockToolCallCountUpdate.mockResolvedValue({}); + mockCaptureEvent.mockResolvedValue(undefined); }); describe('getMcpTools', () => { @@ -315,6 +321,14 @@ describe('getMcpTools', () => { ).rejects.toThrow('External API failed'); expect(mockToolCallCountUpsert).not.toHaveBeenCalled(); expect(mockToolCallCountUpdate).not.toHaveBeenCalled(); + expect(mockCaptureEvent).toHaveBeenCalledWith('ask_mcp_tool_call_completed', expect.objectContaining({ + serverName: 'Linear', + serverUrl: 'https://linear.example.com/mcp', + toolName: 'create_issue', + qualifiedToolName: 'mcp_linear__create_issue', + success: false, + failureReason: 'Error', + })); }); test('tool execute wrapper increments the raw tool call counter after success', async () => { @@ -349,6 +363,39 @@ describe('getMcpTools', () => { }, }); expect(mockToolCallCountUpdate).not.toHaveBeenCalled(); + expect(mockCaptureEvent).toHaveBeenCalledWith('ask_mcp_tool_call_completed', expect.objectContaining({ + source: 'sourcebot-ask-agent', + serverId: 'server-linear', + serverName: 'Linear', + serverUrl: 'https://linear.example.com/mcp', + toolName: 'create_issue', + qualifiedToolName: 'mcp_linear__create_issue', + success: true, + })); + }); + + test('tool execute wrapper includes analytics context in tool completion events', async () => { + const mockClient = createMockMcpClient([ + { name: 'create_issue', description: 'Create issue' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-linear', serverName: 'Linear' }), + ], { + chatId: 'chat-id', + traceId: 'trace-id', + source: 'sourcebot-ask-agent', + }); + + const tool = result.tools['mcp_linear__create_issue']; + await tool.execute({ title: 'My Issue' }, { messages: [], toolCallId: 'test' }); + + expect(mockCaptureEvent).toHaveBeenCalledWith('ask_mcp_tool_call_completed', expect.objectContaining({ + chatId: 'chat-id', + traceId: 'trace-id', + source: 'sourcebot-ask-agent', + })); }); test('tool execute wrapper waits for the counter increment before resolving', async () => { diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index 1c5b6a7fc..4ff1aacf8 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -8,6 +8,7 @@ import { getExternalMcpErrorLogFields } from './externalMcpError'; import { getMcpFaviconUrl } from './utils'; import { __unsafePrisma } from '@/prisma'; import { Prisma } from '@sourcebot/db'; +import { captureEvent } from '@/lib/posthog'; const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); @@ -63,11 +64,39 @@ export interface McpToolsResult { cleanup: () => Promise; } +interface McpToolsAnalyticsContext { + chatId?: string; + traceId?: string; + source: string; +} + +function getMcpToolFailureReason(error: unknown): string { + if (error instanceof McpToolTimeoutError) { + return 'timeout'; + } + + const fields = getExternalMcpErrorLogFields(error); + if (fields.reason) { + return fields.reason; + } + if (fields.oauthError) { + return fields.oauthError; + } + if (fields.statusCode) { + return `status_${fields.statusCode}`; + } + if (fields.errorClass) { + return fields.errorClass; + } + + return 'unknown'; +} + /** * Creates MCPClients from authenticated transports, retrieves their tools, * and returns a namespaced tool record + cleanup function. */ -export async function getMcpTools(clients: McpToolSet[]): Promise { +export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpToolsAnalyticsContext): Promise { const allTools: McpToolsResult['tools'] = {}; const failedServers: string[] = []; const serverFaviconUrls: Record = {}; @@ -131,10 +160,13 @@ export async function getMcpTools(clients: McpToolSet[]): Promise { + const startTime = Date.now(); const timeoutSignal = AbortSignal.timeout(timeoutMs); const combinedSignal = options.abortSignal ? AbortSignal.any([options.abortSignal, timeoutSignal]) : timeoutSignal; + let success = false; + let failureReason: string | undefined; try { const result = await originalExecute(input, { @@ -152,13 +184,31 @@ export async function getMcpTools(clients: McpToolSet[]): Promise !disabledMcpServerIds.includes(c.serverId)); - mcpToolSetsObj = await getMcpTools(mcpClients); + mcpToolSetsObj = await getMcpTools(mcpClients, { + chatId, + traceId, + source: 'sourcebot-ask-agent', + }); for (const [sanitizedName, faviconUrl] of Object.entries(mcpToolSetsObj.serverFaviconUrls)) { onMcpServerDiscovered(sanitizedName, faviconUrl); diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 5bbdf28f3..37a058bcf 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -202,6 +202,19 @@ export type PosthogEventMap = { askMcpFailedServerCount: number, durationMs: number, }, + ask_mcp_tool_call_completed: { + chatId?: string, + traceId?: string, + source: string, + serverId: string, + serverName: string, + serverUrl: string, + toolName: string, + qualifiedToolName: string, + success: boolean, + durationMs: number, + failureReason?: string, + }, tool_used: { toolName: string, source: string, From a62a615aee0a646c2858d366b19913c40c9031eb Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 16:16:51 -0700 Subject: [PATCH 29/40] Add Ask MCP connector lifecycle analytics --- .../accountAskAgent/accountAskAgentPage.tsx | 2 +- .../web/src/app/api/(server)/chat/route.ts | 5 +- .../(server)/ee/askmcp/callback/route.test.ts | 7 ++ .../api/(server)/ee/askmcp/callback/route.ts | 95 +++++++++++++++---- .../(server)/ee/askmcp/connect/route.test.ts | 26 ++++- .../api/(server)/ee/askmcp/connect/route.ts | 36 +++++++ .../web/src/ee/features/mcp/actions.test.ts | 77 ++++++++++++++- packages/web/src/ee/features/mcp/actions.ts | 43 ++++++++- packages/web/src/ee/features/mcp/analytics.ts | 39 ++++++++ .../web/src/ee/features/mcp/mcpToolSets.ts | 5 +- packages/web/src/lib/posthogEvents.ts | 55 ++++++++++- 11 files changed, 362 insertions(+), 28 deletions(-) create mode 100644 packages/web/src/ee/features/mcp/analytics.ts diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index 49979fef4..c007ed1f9 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -300,7 +300,7 @@ export function AccountAskAgentPage({ setDisconnectingServerId(serverId); setConfirmDisconnectServer(null); try { - const result = await disconnectMcpServer(serverId); + const result = await disconnectMcpServer(serverId, 'account_settings'); if (isServiceError(result)) { toast({ title: "Error", description: `Failed to disconnect: ${result.message}`, variant: "destructive" }); return; diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index c3ee79f4b..77379457d 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -93,6 +93,7 @@ export const POST = apiHandler(async (req: NextRequest) => { }))).flat(); const source = req.headers.get('X-Sourcebot-Client-Source') ?? undefined; + const askMcpSource = source === 'sourcebot-web-client' ? source : undefined; const askMcpAvailability = await getAskMcpAvailabilityAnalytics({ prisma, userId: user?.id, @@ -131,9 +132,9 @@ export const POST = apiHandler(async (req: NextRequest) => { availability: askMcpAvailability, }); if (askMcpTurnCompleted) { - await captureEvent('ask_mcp_turn_completed', { + void captureEvent('ask_mcp_turn_completed', { chatId: id, - source, + source: askMcpSource, ...askMcpTurnCompleted, }); } diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index fa9246e53..430cef51d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { NextRequest } from 'next/server'; +import { McpServerClientInfoSource } from '@sourcebot/db'; const mocks = vi.hoisted(() => ({ auth: vi.fn(), @@ -77,6 +78,8 @@ beforeEach(() => { orgId: 1, name: 'Linear', serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, }, }); mocks.unsafePrisma.userMcpServer.update.mockResolvedValue({ userId: 'user-1', serverId: 'server-1' }); @@ -107,6 +110,8 @@ describe('GET /api/ee/askmcp/callback', () => { orgId: true, name: true, serverUrl: true, + sanitizedName: true, + clientInfoSource: true, }, }, }, @@ -158,6 +163,8 @@ describe('GET /api/ee/askmcp/callback', () => { orgId: true, name: true, serverUrl: true, + sanitizedName: true, + clientInfoSource: true, }, }, }, diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index 63661d971..abfb2b1c6 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -12,6 +12,8 @@ import { auth } from '@/auth'; import { NextRequest, NextResponse } from 'next/server'; import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; import { getMcpOAuthReturnToFromState } from '@/features/mcp/mcpOAuthReturnTo'; +import { captureEvent } from '@/lib/posthog'; +import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/mcp/analytics'; const logger = createLogger('mcp-oauth-callback'); const reconnectMessage = 'This connector authorization could not be completed. Please reconnect the connector.'; @@ -59,9 +61,56 @@ export const GET = apiHandler(async (request: NextRequest) => { const code = searchParams.get('code'); const state = searchParams.get('state'); const callbackReturnTo = getMcpOAuthReturnToFromState(state); + const entryPoint = getMcpConnectorEntryPoint(callbackReturnTo); + const getUserServer = () => { + if (!state) { + return Promise.resolve(null); + } + + return prisma.userMcpServer.findFirst({ + where: { + state, + userId: session.user.id, + }, + select: { + serverId: true, + server: { + select: { + orgId: true, + name: true, + serverUrl: true, + sanitizedName: true, + clientInfoSource: true, + }, + }, + }, + }); + }; + const createEventProperties = (userServer: NonNullable>>) => ({ + source: 'sourcebot-web-client' as const, + entryPoint, + serverId: userServer.serverId, + serverName: userServer.server.name, + serverUrl: userServer.server.serverUrl, + sanitizedName: userServer.server.sanitizedName, + authMode: getMcpAuthMode(userServer.server.clientInfoSource), + }); + const getEventProperties = async () => { + const userServer = await getUserServer(); + return userServer ? createEventProperties(userServer) : undefined; + }; // Handle OAuth errors (e.g., user cancelled the authorization flow). if (oauthError) { + // Error callbacks often have no authorization code, so fetch the pending connector here + // only to enrich cancellation/denial analytics when the provider returned state. + const eventProperties = await getEventProperties(); + if (eventProperties) { + void captureEvent('ask_mcp_connector_connection_failed', { + ...eventProperties, + failureReason: 'oauth_error', + }); + } const url = createMcpOAuthRedirectUrl(callbackReturnTo); const errorDescription = searchParams.get('error_description') ?? 'Authorization was cancelled or denied.'; setMcpOAuthStatusParams(url, { status: 'error', message: errorDescription }); @@ -69,36 +118,32 @@ export const GET = apiHandler(async (request: NextRequest) => { } if (!code || !state) { + void captureEvent('ask_mcp_connector_connection_failed', { + source: 'sourcebot-web-client', + entryPoint, + failureReason: 'invalid_request', + }); return Response.json( { error: 'invalid_request', error_description: 'Missing required parameters: code, state.' }, { status: 400 } ); } - const userServer = await prisma.userMcpServer.findFirst({ - where: { - state, - userId: session.user.id, - }, - select: { - serverId: true, - server: { - select: { - orgId: true, - name: true, - serverUrl: true, - }, - }, - }, - }); - + const userServer = await getUserServer(); if (!userServer) { + void captureEvent('ask_mcp_connector_connection_failed', { + source: 'sourcebot-web-client', + entryPoint, + failureReason: 'invalid_state', + }); return Response.json( { error: 'invalid_state', error_description: 'No pending authorization found for this state.' }, { status: 400 } ); } + const connectorEventProperties = createEventProperties(userServer); + const orgMembership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { @@ -109,6 +154,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }); if (!orgMembership) { + void captureEvent('ask_mcp_connector_connection_failed', { + ...connectorEventProperties, + failureReason: 'forbidden', + }); return Response.json( { error: 'forbidden', error_description: 'You do not have access to this connector.' }, { status: 403 } @@ -137,6 +186,10 @@ export const GET = apiHandler(async (request: NextRequest) => { orgId: userServer.server.orgId, error: getExternalMcpErrorLogFields(error), }); + void captureEvent('ask_mcp_connector_connection_failed', { + ...connectorEventProperties, + failureReason: getMcpConnectorFailureReason(error), + }); return redirectToCallbackError(reconnectMessage, callbackReturnTo); } finally { // Always clear ephemeral PKCE/state regardless of outcome to prevent replay. @@ -150,11 +203,19 @@ export const GET = apiHandler(async (request: NextRequest) => { if (result === 'AUTHORIZED') { const displayName = userServer.server.name || userServer.server.serverUrl; logger.info(`Successfully authorized MCP server ${displayName} for user ${session.user.id}.`); + void captureEvent('ask_mcp_connector_connection_completed', { + ...connectorEventProperties, + alreadyAuthorized: false, + }); const url = createMcpOAuthRedirectUrl(callbackReturnTo); setMcpOAuthStatusParams(url, { status: 'connected', server: displayName }); return NextResponse.redirect(url); } // If auth() didn't return AUTHORIZED, something went wrong + void captureEvent('ask_mcp_connector_connection_failed', { + ...connectorEventProperties, + failureReason: 'token_exchange_failed', + }); return redirectToCallbackError('Token exchange failed', callbackReturnTo); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index dfd36a45d..550e2b93c 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -16,11 +16,12 @@ const mocks = vi.hoisted(() => ({ unsafePrisma: { $transaction: vi.fn(), }, + captureEvent: vi.fn(), })); vi.mock('server-only', () => ({})); vi.mock('@/lib/posthog', () => ({ - captureEvent: vi.fn(), + captureEvent: mocks.captureEvent, })); vi.mock('@/lib/entitlements', () => ({ hasEntitlement: mocks.hasEntitlement, @@ -82,7 +83,10 @@ function createPrismaMock() { mcpServer: { findFirst: vi.fn().mockResolvedValue({ id: 'server-1', + name: 'Linear', + sanitizedName: 'linear', serverUrl: 'https://mcp.linear.app/mcp', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, }), }, userMcpServer: { @@ -109,6 +113,7 @@ function createTransactionMock() { beforeEach(() => { vi.clearAllMocks(); mocks.hasEntitlement.mockResolvedValue(true); + mocks.captureEvent.mockResolvedValue(undefined); }); describe('POST /api/ee/askmcp/connect', () => { @@ -151,6 +156,15 @@ describe('POST /api/ee/askmcp/connect', () => { const response = await POST(createRequest()); const body = await response.json(); + expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_connection_started', { + source: 'sourcebot-web-client', + entryPoint: 'unknown', + serverId: 'server-1', + serverName: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + authMode: 'dynamic', + }); expect(prisma.userMcpServer.upsert).toHaveBeenCalledWith({ where: { userId_serverId: { @@ -286,5 +300,15 @@ describe('POST /api/ee/askmcp/connect', () => { expect(JSON.stringify(mocks.logger.warn.mock.calls)).not.toContain('refresh-token'); expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain('client-secret'); expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain('refresh-token'); + expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_connection_failed', { + source: 'sourcebot-web-client', + entryPoint: 'unknown', + serverId: 'server-1', + serverName: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + authMode: 'dynamic', + failureReason: 'invalid_client', + }); }); }); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index f8950eae4..5c558793c 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -16,6 +16,8 @@ import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError import { ErrorCode } from '@/lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { normalizeMcpOAuthReturnTo } from '@/features/mcp/mcpOAuthReturnTo'; +import { captureEvent } from '@/lib/posthog'; +import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/mcp/analytics'; const bodySchema = z.object({ serverId: z.string(), @@ -67,17 +69,39 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = await sew(() => withAuth(async ({ user, org, prisma }) => { const callbackReturnTo = normalizeMcpOAuthReturnTo(parsed.data.returnTo); + const entryPoint = getMcpConnectorEntryPoint(parsed.data.returnTo); const mcpServer = await prisma.mcpServer.findFirst({ where: { id: parsed.data.serverId, orgId: org.id }, select: { id: true, + name: true, + sanitizedName: true, serverUrl: true, + clientInfoSource: true, }, }); if (!mcpServer) { + void captureEvent('ask_mcp_connector_connection_failed', { + source: 'sourcebot-web-client', + entryPoint, + serverId: parsed.data.serverId, + failureReason: 'connector_not_found', + }); return notFound('Connector not found'); } + const eventProperties = { + source: 'sourcebot-web-client' as const, + entryPoint, + serverId: mcpServer.id, + serverName: mcpServer.name, + serverUrl: mcpServer.serverUrl, + sanitizedName: mcpServer.sanitizedName, + authMode: getMcpAuthMode(mcpServer.clientInfoSource), + }; + + void captureEvent('ask_mcp_connector_connection_started', eventProperties); + await prisma.userMcpServer.upsert({ where: { userId_serverId: { @@ -127,6 +151,10 @@ export const POST = apiHandler(async (request: NextRequest) => { orgId: org.id, error: getExternalMcpErrorLogFields(error), }); + void captureEvent('ask_mcp_connector_connection_failed', { + ...eventProperties, + failureReason: getMcpConnectorFailureReason(error), + }); throw new ServiceErrorException({ statusCode: StatusCodes.BAD_GATEWAY, errorCode: ErrorCode.UNEXPECTED_ERROR, @@ -145,10 +173,18 @@ export const POST = apiHandler(async (request: NextRequest) => { if (connectResult.authResult === 'AUTHORIZED') { // Already has valid tokens (e.g., refreshed) + void captureEvent('ask_mcp_connector_connection_completed', { + ...eventProperties, + alreadyAuthorized: true, + }); return { authorizationUrl: null } satisfies ConnectMcpResponse; } if (!connectResult.authorizationUrl) { + void captureEvent('ask_mcp_connector_connection_failed', { + ...eventProperties, + failureReason: 'missing_authorization_url', + }); throw new Error('MCP auth returned REDIRECT without an authorization URL'); } diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/mcp/actions.test.ts index 507dc28f7..1aefc0da8 100644 --- a/packages/web/src/ee/features/mcp/actions.test.ts +++ b/packages/web/src/ee/features/mcp/actions.test.ts @@ -19,9 +19,14 @@ const mocks = vi.hoisted(() => ({ logger: { error: vi.fn(), }, + captureEvent: vi.fn(), unsafePrisma: { mcpServer: { deleteMany: vi.fn(), + findFirst: vi.fn(), + }, + userMcpServer: { + deleteMany: vi.fn(), }, }, })); @@ -44,8 +49,11 @@ vi.mock('@sourcebot/shared', () => ({ encryptOAuthToken: mocks.encryptOAuthToken, env: mocks.env, })); +vi.mock('@/lib/posthog', () => ({ + captureEvent: mocks.captureEvent, +})); -const { createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } = await import('./actions'); +const { createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, disconnectMcpServer } = await import('./actions'); function createPrismaMock() { return { @@ -98,6 +106,7 @@ beforeEach(() => { mocks.env.AUTH_URL = 'https://sourcebot.example.com'; mocks.env.NODE_ENV = 'production'; mocks.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = 5000; + mocks.captureEvent.mockResolvedValue(undefined); }); describe('createMcpServer', () => { @@ -122,6 +131,15 @@ describe('createMcpServer', () => { orgId: 1, }, }); + expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_added', { + source: 'sourcebot-web-client', + entryPoint: 'workspace_settings', + serverId: 'server-1', + serverName: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + authMode: 'dynamic', + }); }); test('members cannot add org MCP servers', async () => { @@ -181,6 +199,15 @@ describe('createStaticOAuthMcpServer', () => { sanitizedName: 'slack', serverUrl: 'https://mcp.slack.com/mcp', }); + expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_added', { + source: 'sourcebot-web-client', + entryPoint: 'workspace_settings', + serverId: 'server-1', + serverName: 'Slack', + serverUrl: 'https://mcp.slack.com/mcp', + sanitizedName: 'slack', + authMode: 'static', + }); }); test('members cannot add static OAuth MCP servers', async () => { @@ -384,3 +411,51 @@ describe('deleteMcpServer', () => { }); }); }); + +describe('disconnectMcpServer', () => { + test('disconnects a personal connector and tracks the disconnect', async () => { + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + }; + mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue({ + id: 'server-1', + name: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }); + mocks.unsafePrisma.userMcpServer.deleteMany.mockResolvedValue({ count: 1 }); + + await expect(disconnectMcpServer('server-1', 'account_settings')).resolves.toEqual({ success: true }); + + expect(mocks.unsafePrisma.mcpServer.findFirst).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + }, + select: { + id: true, + name: true, + serverUrl: true, + sanitizedName: true, + clientInfoSource: true, + }, + }); + expect(mocks.unsafePrisma.userMcpServer.deleteMany).toHaveBeenCalledWith({ + where: { + serverId: 'server-1', + userId: 'user-1', + }, + }); + expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_disconnected', { + source: 'sourcebot-web-client', + entryPoint: 'account_settings', + serverId: 'server-1', + serverName: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + authMode: 'dynamic', + }); + }); +}); diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/mcp/actions.ts index fe765b8b4..c1c3528e9 100644 --- a/packages/web/src/ee/features/mcp/actions.ts +++ b/packages/web/src/ee/features/mcp/actions.ts @@ -16,6 +16,9 @@ import { oauthNotSupported } from './errors'; import { checkMcpServerDcrSupport } from './dcrDiscovery'; import { encryptOAuthToken, env } from '@sourcebot/shared'; import { headers } from 'next/headers'; +import { captureEvent } from '@/lib/posthog'; +import { getMcpAuthMode } from './analytics'; +import type { McpConnectorEntryPoint } from '@/lib/posthogEvents'; const MCP_DCR_DISCOVERY_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 10000); const createStaticOAuthMcpServerSchema = z.object({ @@ -253,6 +256,16 @@ export const createStaticOAuthMcpServer = async ( }, }); + void captureEvent('ask_mcp_connector_added', { + source: 'sourcebot-web-client', + entryPoint: 'workspace_settings', + serverId: mcpServer.id, + serverName: preparedServer.displayName, + serverUrl: mcpServer.serverUrl, + sanitizedName: preparedServer.sanitizedName, + authMode: 'static', + }); + return { id: mcpServer.id, name: preparedServer.displayName, @@ -290,6 +303,16 @@ export const createMcpServer = async (name: string, serverUrl: string) => sew(() }, }); + void captureEvent('ask_mcp_connector_added', { + source: 'sourcebot-web-client', + entryPoint: 'workspace_settings', + serverId: mcpServer.id, + serverName: preparedServer.displayName, + serverUrl: mcpServer.serverUrl, + sanitizedName: preparedServer.sanitizedName, + authMode: getMcpAuthMode(McpServerClientInfoSource.DYNAMIC), + }); + return { id: mcpServer.id, name: preparedServer.displayName, @@ -319,14 +342,20 @@ export const deleteMcpServer = async (serverId: string) => sew(() => return { success: true }; }))); -export const disconnectMcpServer = async (serverId: string) => sew(() => +export const disconnectMcpServer = async (serverId: string, entryPoint: McpConnectorEntryPoint) => sew(() => withAuth(async ({ org, user }) => { const server = await __unsafePrisma.mcpServer.findFirst({ where: { id: serverId, orgId: org.id, }, - select: { id: true }, + select: { + id: true, + name: true, + serverUrl: true, + sanitizedName: true, + clientInfoSource: true, + }, }); if (!server) { @@ -352,5 +381,15 @@ export const disconnectMcpServer = async (serverId: string) => sew(() => } satisfies ServiceError; } + void captureEvent('ask_mcp_connector_disconnected', { + source: 'sourcebot-web-client', + entryPoint, + serverId: server.id, + serverName: server.name, + serverUrl: server.serverUrl, + sanitizedName: server.sanitizedName, + authMode: getMcpAuthMode(server.clientInfoSource), + }); + return { success: true }; })); diff --git a/packages/web/src/ee/features/mcp/analytics.ts b/packages/web/src/ee/features/mcp/analytics.ts new file mode 100644 index 000000000..b21fe7848 --- /dev/null +++ b/packages/web/src/ee/features/mcp/analytics.ts @@ -0,0 +1,39 @@ +import { McpServerClientInfoSource } from '@sourcebot/db'; +import type { McpConnectorAuthMode, McpConnectorEntryPoint } from '@/lib/posthogEvents'; +import { getExternalMcpErrorLogFields } from './externalMcpError'; + +export function getMcpConnectorEntryPoint(returnTo: string | undefined): McpConnectorEntryPoint { + if (returnTo?.startsWith('/chat')) { + return 'chat'; + } + if (returnTo?.startsWith('/settings/accountAskAgent')) { + return 'account_settings'; + } + if (returnTo?.startsWith('/settings/workspaceAskAgent')) { + return 'workspace_settings'; + } + + return 'unknown'; +} + +export function getMcpAuthMode(clientInfoSource: McpServerClientInfoSource): McpConnectorAuthMode { + return clientInfoSource === McpServerClientInfoSource.STATIC ? 'static' : 'dynamic'; +} + +export function getMcpConnectorFailureReason(error: unknown): string { + const fields = getExternalMcpErrorLogFields(error); + if (fields.reason) { + return fields.reason; + } + if (fields.oauthError) { + return fields.oauthError; + } + if (fields.statusCode) { + return `status_${fields.statusCode}`; + } + if (fields.errorClass) { + return fields.errorClass; + } + + return 'unknown'; +} diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/mcp/mcpToolSets.ts index 4ff1aacf8..4e249247b 100644 --- a/packages/web/src/ee/features/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/mcp/mcpToolSets.ts @@ -9,6 +9,7 @@ import { getMcpFaviconUrl } from './utils'; import { __unsafePrisma } from '@/prisma'; import { Prisma } from '@sourcebot/db'; import { captureEvent } from '@/lib/posthog'; +import type { AskMcpAnalyticsSource } from '@/lib/posthogEvents'; const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); @@ -67,7 +68,7 @@ export interface McpToolsResult { interface McpToolsAnalyticsContext { chatId?: string; traceId?: string; - source: string; + source: AskMcpAnalyticsSource; } function getMcpToolFailureReason(error: unknown): string { @@ -196,7 +197,7 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT failureReason = getMcpToolFailureReason(error); throw error; } finally { - await captureEvent('ask_mcp_tool_call_completed', { + void captureEvent('ask_mcp_tool_call_completed', { chatId: analyticsContext?.chatId, traceId: analyticsContext?.traceId, source: analyticsContext?.source ?? 'sourcebot-ask-agent', diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 37a058bcf..4326036d0 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -7,6 +7,10 @@ export type UpsellSource = 'onboard' | 'license_settings'; +export type SourcebotWebClientSource = 'sourcebot-web-client'; +export type AskMcpAnalyticsSource = SourcebotWebClientSource | 'sourcebot-ask-agent'; +export type McpConnectorEntryPoint = 'chat' | 'account_settings' | 'workspace_settings' | 'unknown'; +export type McpConnectorAuthMode = 'dynamic' | 'static'; export type PosthogEventMap = { search_finished: { @@ -191,7 +195,7 @@ export type PosthogEventMap = { }, ask_mcp_turn_completed: { chatId: string, - source?: string, + source?: SourcebotWebClientSource, traceId?: string, askMcpUsed: boolean, askMcpToolCallCount: number, @@ -205,7 +209,7 @@ export type PosthogEventMap = { ask_mcp_tool_call_completed: { chatId?: string, traceId?: string, - source: string, + source: AskMcpAnalyticsSource, serverId: string, serverName: string, serverUrl: string, @@ -215,6 +219,53 @@ export type PosthogEventMap = { durationMs: number, failureReason?: string, }, + ask_mcp_connector_added: { + source: SourcebotWebClientSource, + entryPoint: 'workspace_settings', + serverId: string, + serverName: string, + serverUrl: string, + sanitizedName: string, + authMode: McpConnectorAuthMode, + }, + ask_mcp_connector_connection_started: { + source: SourcebotWebClientSource, + entryPoint: McpConnectorEntryPoint, + serverId: string, + serverName: string, + serverUrl: string, + sanitizedName: string, + authMode: McpConnectorAuthMode, + }, + ask_mcp_connector_connection_completed: { + source: SourcebotWebClientSource, + entryPoint: McpConnectorEntryPoint, + serverId: string, + serverName: string, + serverUrl: string, + sanitizedName: string, + authMode: McpConnectorAuthMode, + alreadyAuthorized: boolean, + }, + ask_mcp_connector_connection_failed: { + source: SourcebotWebClientSource, + entryPoint: McpConnectorEntryPoint, + serverId?: string, + serverName?: string, + serverUrl?: string, + sanitizedName?: string, + authMode?: McpConnectorAuthMode, + failureReason: string, + }, + ask_mcp_connector_disconnected: { + source: SourcebotWebClientSource, + entryPoint: McpConnectorEntryPoint, + serverId: string, + serverName: string, + serverUrl: string, + sanitizedName: string, + authMode: McpConnectorAuthMode, + }, tool_used: { toolName: string, source: string, From 1d62399ddd6569b5a54a2612bf0a31ca083d4805 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 17:48:17 -0700 Subject: [PATCH 30/40] Fix v5 rebase follow-ups --- .../app/(app)/@sidebar/components/settingsSidebar/nav.tsx | 5 ++++- .../web/src/app/(app)/@sidebar/components/sidebarBase.tsx | 6 +++--- packages/web/src/app/(app)/settings/layout.tsx | 5 +++++ .../web/src/app/api/(server)/ee/askmcp/connect/route.ts | 2 +- packages/web/src/middleware/withAuth.test.ts | 1 + 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 7920b992c..11960732a 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -25,6 +25,8 @@ import { UserIcon, UsersIcon, } from "lucide-react"; +import { IconType } from "react-icons/lib"; +import { VscMcp } from "react-icons/vsc"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { UpgradeBadge } from "../upgradeBadge"; @@ -40,8 +42,9 @@ const iconMap = { "server": ServerIcon, "settings": Settings2Icon, "user": UserIcon, + "mcp": VscMcp, "bot": BotIcon, -} satisfies Record; +} satisfies Record; export type NavIconName = keyof typeof iconMap; diff --git a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx index 8a0a920cf..6083c12e4 100644 --- a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx @@ -262,10 +262,10 @@ const GuestDropdownMenu = () => { - + Sign in - + @@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => { ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 515e4fb51..604601027 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -88,6 +88,11 @@ export const getSidebarNavGroups = async () => icon: "link" as const, } ] : []), + { + title: "MCP Server", + href: `/settings/mcp`, + icon: 'mcp' as const, + }, ...(hasOAuthEntitlement ? [ { title: "Ask Agent", diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index 5c558793c..ea18f39b1 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -195,6 +195,6 @@ export const POST = apiHandler(async (request: NextRequest) => { if (isServiceError(result)) { return serviceErrorResponse(result); } - + return Response.json(result); }); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 6da2a9afe..fa4d2433f 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -86,6 +86,7 @@ beforeEach(() => { mocks.headers.mockResolvedValue(new Headers()); mocks.hasEntitlement.mockReturnValue(false); mocks.isAnonymousAccessAvailable.mockReturnValue(false); + prisma.user.update.mockResolvedValue(MOCK_USER_WITH_ACCOUNTS); // Reset env flags between tests Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); From 89033906022635cea05e37ccdab01b1456c106c3 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 18:34:29 -0700 Subject: [PATCH 31/40] Clean up Ask MCP deployment references --- .../20260524000000_org_approved_mcp_servers/migration.sql | 2 +- .../api/(server)/ee/askmcp/configuration/route.test.ts | 2 +- .../src/app/api/(server)/ee/askmcp/connect/route.test.ts | 8 ++++---- .../src/app/api/(server)/ee/askmcp/servers/route.test.ts | 2 +- .../src/app/api/(server)/ee/askmcp/tools/route.test.ts | 2 +- packages/web/src/ee/features/mcp/mcpClientFactory.test.ts | 2 +- packages/web/src/features/chat/agent.test.ts | 2 +- .../features/chat/components/chatBox/chatBoxToolbar.tsx | 2 -- packages/web/src/features/chat/mcpOAuthDraft.ts | 2 +- packages/web/src/features/mcp/mcpOAuthReturnTo.ts | 2 +- 10 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql b/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql index 99d1bc446..64844aedb 100644 --- a/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql +++ b/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql @@ -2,7 +2,7 @@ ALTER TABLE "McpServer" ADD COLUMN "name" TEXT; ALTER TABLE "McpServer" ADD COLUMN "sanitizedName" TEXT; --- This branch has not shipped, but keep local development databases migratable. +-- Backfill existing rows before enforcing non-null display identity. UPDATE "McpServer" SET "name" = COALESCE( ( diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts index b134252a0..1c868baae 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.test.ts @@ -33,7 +33,7 @@ vi.mock('@/prisma', () => ({ const { GET } = await import('./route'); function createRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/configuration', { method: 'GET' }); + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/configuration', { method: 'GET' }); } function createPrismaMock() { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 550e2b93c..6b9561ac6 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -49,7 +49,7 @@ const { POST } = await import('./route'); const { getMcpOAuthReturnToFromState } = await import('@/features/mcp/mcpOAuthReturnTo'); function createRequest(body: { serverId: string; returnTo?: string } = { serverId: 'server-1' }) { - return new NextRequest('http://localhost/api/ee/askmcp/connect', { + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -57,7 +57,7 @@ function createRequest(body: { serverId: string; returnTo?: string } = { serverI } function createMalformedJsonRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/connect', { + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"serverId":', @@ -65,7 +65,7 @@ function createMalformedJsonRequest() { } function createTextPlainRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/connect', { + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/connect', { method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'server-1', @@ -73,7 +73,7 @@ function createTextPlainRequest() { } function createEmptyBodyRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/connect', { + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/connect', { method: 'POST', }); } diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts index 5fe917f02..0a272ff8d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts @@ -22,7 +22,7 @@ vi.mock('@sourcebot/shared', () => ({ const { GET } = await import('./route'); function createRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/servers', { method: 'GET' }); + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/servers', { method: 'GET' }); } function createPrismaMock() { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts index 06f2f53b5..e4c2a328c 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts @@ -23,7 +23,7 @@ vi.mock('@/ee/features/mcp/mcpToolMetadata', () => ({ const { GET } = await import('./route'); function createRequest() { - return new NextRequest('http://localhost/api/ee/askmcp/tools', { method: 'GET' }); + return new NextRequest('https://sourcebot.example.com/api/ee/askmcp/tools', { method: 'GET' }); } beforeEach(() => { diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts index f4350a135..4333449bc 100644 --- a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts +++ b/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts @@ -11,7 +11,7 @@ vi.mock('@sourcebot/shared', () => ({ error: vi.fn(), debug: vi.fn(), }), - env: { AUTH_URL: 'http://localhost:3000' }, + env: { AUTH_URL: 'https://sourcebot.example.com' }, decryptOAuthToken: vi.fn((s: string) => s), })); diff --git a/packages/web/src/features/chat/agent.test.ts b/packages/web/src/features/chat/agent.test.ts index f28fe8ab5..1fdac1e6b 100644 --- a/packages/web/src/features/chat/agent.test.ts +++ b/packages/web/src/features/chat/agent.test.ts @@ -26,7 +26,7 @@ vi.mock('@sourcebot/shared', () => ({ SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0, SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false', }, - getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@localhost:5432/sourcebot', + getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot', })); vi.mock('server-only', () => ({})); diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx index 9edf84cb1..dc905a768 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx @@ -17,8 +17,6 @@ export interface ChatBoxToolbarProps { onSelectedSearchScopesChange: (items: SearchScope[]) => void; isContextSelectorOpen: boolean; onContextSelectorOpenChanged: (isOpen: boolean) => void; - // TODO_Jack_MakeLinearTask: Make the plus button available on simplified toolbar usages (e.g. askgh) - // once additional features (beyond MCP server toggling) are added to it. disabledMcpServerIds?: string[]; onDisabledMcpServerIdsChange?: (ids: string[]) => void; } diff --git a/packages/web/src/features/chat/mcpOAuthDraft.ts b/packages/web/src/features/chat/mcpOAuthDraft.ts index 19f00f84f..bbbf2a146 100644 --- a/packages/web/src/features/chat/mcpOAuthDraft.ts +++ b/packages/web/src/features/chat/mcpOAuthDraft.ts @@ -2,7 +2,7 @@ import type { Descendant } from "slate"; import { MCP_OAUTH_DRAFT_SESSION_STORAGE_KEY } from "./constants"; import type { CustomText, MentionElement, ParagraphElement, SearchScope } from "./types"; -const MCP_OAUTH_DRAFT_BASE_URL = 'https://sourcebot.local'; +const MCP_OAUTH_DRAFT_BASE_URL = 'https://sourcebot.invalid'; const MCP_OAUTH_DRAFT_MAX_AGE_MS = 30 * 60 * 1000; const MCP_OAUTH_STATUS_PARAMS = ['status', 'server', 'message']; diff --git a/packages/web/src/features/mcp/mcpOAuthReturnTo.ts b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts index 0841455fa..8127abdbc 100644 --- a/packages/web/src/features/mcp/mcpOAuthReturnTo.ts +++ b/packages/web/src/features/mcp/mcpOAuthReturnTo.ts @@ -1,5 +1,5 @@ const MCP_OAUTH_STATE_PREFIX = 'sourcebot_mcp.'; -const MCP_OAUTH_STATE_BASE_URL = 'https://sourcebot.local'; +const MCP_OAUTH_STATE_BASE_URL = 'https://sourcebot.invalid'; function isAllowedMcpOAuthReturnPath(pathname: string): boolean { return pathname === '/chat' || pathname.startsWith('/chat/') || pathname === '/settings/accountAskAgent'; From 209da4d88eb4b528c7aa654094ce7809668e48c5 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 20:34:23 -0700 Subject: [PATCH 32/40] Move EE MCP feature under chat --- .../accountAskAgentPage.test.tsx | 2 +- .../accountAskAgent/accountAskAgentPage.tsx | 20 +++++++++---------- .../prefabConnectorPopover.tsx | 8 ++++---- .../workspaceAskAgentPage.tsx | 16 +++++++-------- packages/web/src/app/api/(client)/client.ts | 2 +- .../api/(server)/ee/askmcp/callback/route.ts | 4 ++-- .../(server)/ee/askmcp/configuration/route.ts | 4 ++-- .../api/(server)/ee/askmcp/connect/route.ts | 4 ++-- .../api/(server)/ee/askmcp/servers/route.ts | 4 ++-- .../(server)/ee/askmcp/tools/route.test.ts | 2 +- .../app/api/(server)/ee/askmcp/tools/route.ts | 2 +- .../features/{ => chat}/mcp/actions.test.ts | 0 .../src/ee/features/{ => chat}/mcp/actions.ts | 0 .../ee/features/{ => chat}/mcp/analytics.ts | 0 .../mcp/components/connectMcpButton.tsx | 2 +- .../mcp/components/connectorCard.test.tsx | 2 +- .../mcp/components/connectorCard.tsx | 8 ++++---- .../mcp/components/connectorRowInfo.tsx | 0 .../connectorToolDisclosure.test.tsx | 2 +- .../components/connectorToolDisclosure.tsx | 4 ++-- .../connectorToolUsageDisclosure.test.tsx | 2 +- .../connectorToolUsageDisclosure.tsx | 4 ++-- .../{ => chat}/mcp/components/mcpFavicon.tsx | 0 .../{ => chat}/mcp/connectionStatus.test.ts | 0 .../{ => chat}/mcp/connectionStatus.ts | 0 .../{ => chat}/mcp/dcrDiscovery.test.ts | 0 .../features/{ => chat}/mcp/dcrDiscovery.ts | 0 .../src/ee/features/{ => chat}/mcp/errors.ts | 0 .../{ => chat}/mcp/externalMcpError.test.ts | 0 .../{ => chat}/mcp/externalMcpError.ts | 0 .../{ => chat}/mcp/hooks/useConnectMcp.ts | 2 +- .../mcp/hooks/useMcpToolMetadata.ts | 4 ++-- .../{ => chat}/mcp/mcpClientFactory.test.ts | 0 .../{ => chat}/mcp/mcpClientFactory.ts | 0 .../{ => chat}/mcp/mcpToolMetadata.test.ts | 0 .../{ => chat}/mcp/mcpToolMetadata.ts | 0 .../{ => chat}/mcp/mcpToolRegistry.test.ts | 0 .../{ => chat}/mcp/mcpToolRegistry.ts | 0 .../{ => chat}/mcp/mcpToolSets.test.ts | 0 .../ee/features/{ => chat}/mcp/mcpToolSets.ts | 0 .../{ => chat}/mcp/prefabMcpServers.test.ts | 0 .../{ => chat}/mcp/prefabMcpServers.ts | 0 .../features/{ => chat}/mcp/queryKeys.test.ts | 0 .../ee/features/{ => chat}/mcp/queryKeys.ts | 0 .../src/ee/features/{ => chat}/mcp/types.ts | 0 .../ee/features/{ => chat}/mcp/utils.test.ts | 0 .../src/ee/features/{ => chat}/mcp/utils.ts | 0 packages/web/src/features/chat/agent.test.ts | 6 +++--- packages/web/src/features/chat/agent.ts | 6 +++--- .../features/chat/askMcpAnalytics.server.ts | 2 +- .../components/chatBox/chatBoxPlusButton.tsx | 4 ++-- .../chatThread/toolApprovalBanner.tsx | 2 +- .../chatThread/tools/mcpToolComponent.tsx | 2 +- 53 files changed, 60 insertions(+), 60 deletions(-) rename packages/web/src/ee/features/{ => chat}/mcp/actions.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/actions.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/analytics.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectMcpButton.tsx (94%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorCard.test.tsx (99%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorCard.tsx (94%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorRowInfo.tsx (100%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorToolDisclosure.test.tsx (98%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorToolDisclosure.tsx (98%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorToolUsageDisclosure.test.tsx (98%) rename packages/web/src/ee/features/{ => chat}/mcp/components/connectorToolUsageDisclosure.tsx (98%) rename packages/web/src/ee/features/{ => chat}/mcp/components/mcpFavicon.tsx (100%) rename packages/web/src/ee/features/{ => chat}/mcp/connectionStatus.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/connectionStatus.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/dcrDiscovery.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/dcrDiscovery.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/errors.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/externalMcpError.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/externalMcpError.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/hooks/useConnectMcp.ts (98%) rename packages/web/src/ee/features/{ => chat}/mcp/hooks/useMcpToolMetadata.ts (94%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpClientFactory.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpClientFactory.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolMetadata.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolMetadata.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolRegistry.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolRegistry.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolSets.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/mcpToolSets.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/prefabMcpServers.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/prefabMcpServers.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/queryKeys.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/queryKeys.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/types.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/utils.test.ts (100%) rename packages/web/src/ee/features/{ => chat}/mcp/utils.ts (100%) diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx index 6fb241298..2da1a2fa9 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.test.tsx @@ -5,7 +5,7 @@ vi.mock('@/app/api/(client)/client', () => ({ getMcpServersWithStatus: vi.fn(), getMcpServerTools: vi.fn(), })); -vi.mock('@/ee/features/mcp/actions', () => ({ +vi.mock('@/ee/features/chat/mcp/actions', () => ({ disconnectMcpServer: vi.fn(), })); diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx index c007ed1f9..4f69fcf1d 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentPage.tsx @@ -18,18 +18,18 @@ import { import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; -import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; -import { ConnectorRowInfo } from "@/ee/features/mcp/components/connectorRowInfo"; -import { ConnectorToolTrigger } from "@/ee/features/mcp/components/connectorToolDisclosure"; -import { useConnectMcp } from "@/ee/features/mcp/hooks/useConnectMcp"; -import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; -import { disconnectMcpServer } from "@/ee/features/mcp/actions"; -import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { pluralize } from "@/ee/features/mcp/utils"; +import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton"; +import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; +import { ConnectorRowInfo } from "@/ee/features/chat/mcp/components/connectorRowInfo"; +import { ConnectorToolTrigger } from "@/ee/features/chat/mcp/components/connectorToolDisclosure"; +import { useConnectMcp } from "@/ee/features/chat/mcp/hooks/useConnectMcp"; +import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; +import { disconnectMcpServer } from "@/ee/features/chat/mcp/actions"; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; +import { pluralize } from "@/ee/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import type { McpServerWithStatus } from "@/app/api/(server)/ee/askmcp/servers/route"; -import type { ServerToolsEntry } from "@/ee/features/mcp/types"; +import type { ServerToolsEntry } from "@/ee/features/chat/mcp/types"; type FilterTab = "all" | "connected"; diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx index 86fc21969..479b8c09e 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/prefabConnectorPopover.tsx @@ -11,13 +11,13 @@ import { } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { getDisplayServerUrl } from "@/ee/features/mcp/components/connectorRowInfo"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { getDisplayServerUrl } from "@/ee/features/chat/mcp/components/connectorRowInfo"; +import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; import { getAvailablePrefabMcpServers, type PrefabMcpServer, -} from "@/ee/features/mcp/prefabMcpServers"; -import { getMcpFaviconUrl } from "@/ee/features/mcp/utils"; +} from "@/ee/features/chat/mcp/prefabMcpServers"; +import { getMcpFaviconUrl } from "@/ee/features/chat/mcp/utils"; import { PlusIcon } from "lucide-react"; interface PrefabConnectorPopoverProps { diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 1cba08e1f..27507932e 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -19,18 +19,18 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/mcp/actions"; -import { ConnectMcpButton } from "@/ee/features/mcp/components/connectMcpButton"; -import { ConnectorCard } from "@/ee/features/mcp/components/connectorCard"; -import { useMcpToolMetadata } from "@/ee/features/mcp/hooks/useMcpToolMetadata"; -import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; -import { pluralize } from "@/ee/features/mcp/utils"; +import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/chat/mcp/actions"; +import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton"; +import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; +import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; +import { pluralize } from "@/ee/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { PrefabConnectorPopover } from "./prefabConnectorPopover"; -import type { PrefabMcpServer } from "@/ee/features/mcp/prefabMcpServers"; -import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/mcp/types"; +import type { PrefabMcpServer } from "@/ee/features/chat/mcp/prefabMcpServers"; +import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/chat/mcp/types"; function clearCallbackParams() { const url = new URL(window.location.href); diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 083723ef4..ecc95818a 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -33,7 +33,7 @@ import type { import { OffersResponse } from "@/ee/features/lighthouse/types"; import { ConnectMcpResponse } from "../(server)/ee/askmcp/connect/types"; import type { GetMcpServersResponse } from "../(server)/ee/askmcp/servers/route"; -import type { GetMcpConfigurationResponse, GetMcpToolsResponse } from "@/ee/features/mcp/types"; +import type { GetMcpConfigurationResponse, GetMcpToolsResponse } from "@/ee/features/chat/mcp/types"; export const search = async (body: SearchRequest): Promise => { const result = await fetch("/api/search", { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index abfb2b1c6..c2d15704e 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -10,10 +10,10 @@ import { PrismaOAuthClientProvider } from '@/features/mcp/prismaOAuthClientProvi import { __unsafePrisma as prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextRequest, NextResponse } from 'next/server'; -import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; +import { getExternalMcpErrorLogFields } from '@/ee/features/chat/mcp/externalMcpError'; import { getMcpOAuthReturnToFromState } from '@/features/mcp/mcpOAuthReturnTo'; import { captureEvent } from '@/lib/posthog'; -import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/mcp/analytics'; +import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/chat/mcp/analytics'; const logger = createLogger('mcp-oauth-callback'); const reconnectMessage = 'This connector authorization could not be completed. Please reconnect the connector.'; diff --git a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts index 49611f2e0..b4d3949a6 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/configuration/route.ts @@ -5,8 +5,8 @@ import { hasEntitlement } from '@/lib/entitlements'; import { withAuth } from '@/middleware/withAuth'; import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; import { __unsafePrisma } from '@/prisma'; -import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; -import type { GetMcpConfigurationResponse, McpServerToolUsageSummary } from '@/ee/features/mcp/types'; +import { getMcpFaviconUrl } from '@/ee/features/chat/mcp/utils'; +import type { GetMcpConfigurationResponse, McpServerToolUsageSummary } from '@/ee/features/chat/mcp/types'; import { OrgRole } from '@sourcebot/db'; import type { NextRequest } from 'next/server'; diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts index ea18f39b1..a6b3aff02 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.ts @@ -12,12 +12,12 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants import { ConnectMcpResponse } from "@/app/api/(server)/ee/askmcp/connect/types"; import { createLogger, env } from "@sourcebot/shared"; import { __unsafePrisma } from '@/prisma'; -import { getExternalMcpErrorLogFields } from '@/ee/features/mcp/externalMcpError'; +import { getExternalMcpErrorLogFields } from '@/ee/features/chat/mcp/externalMcpError'; import { ErrorCode } from '@/lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { normalizeMcpOAuthReturnTo } from '@/features/mcp/mcpOAuthReturnTo'; import { captureEvent } from '@/lib/posthog'; -import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/mcp/analytics'; +import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/chat/mcp/analytics'; const bodySchema = z.object({ serverId: z.string(), diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index d3f3f4ea9..fb6053209 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -4,8 +4,8 @@ import { isServiceError } from '@/lib/utils'; import { withAuth } from '@/middleware/withAuth'; import { hasEntitlement } from '@/lib/entitlements'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; -import { getMcpFaviconUrl } from '@/ee/features/mcp/utils'; -import { getStoredMcpConnectionStatus } from '@/ee/features/mcp/connectionStatus'; +import { getMcpFaviconUrl } from '@/ee/features/chat/mcp/utils'; +import { getStoredMcpConnectionStatus } from '@/ee/features/chat/mcp/connectionStatus'; import type { NextRequest } from 'next/server'; export interface McpServerWithStatus { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts index e4c2a328c..79cf8164d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/entitlements', () => ({ vi.mock('@/middleware/withAuth', () => ({ withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), })); -vi.mock('@/ee/features/mcp/mcpToolMetadata', () => ({ +vi.mock('@/ee/features/chat/mcp/mcpToolMetadata', () => ({ getMcpToolMetadata: mocks.getMcpToolMetadata, })); diff --git a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts index 2d6dce0a1..aea01a7e7 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/tools/route.ts @@ -4,7 +4,7 @@ import { isServiceError } from '@/lib/utils'; import { withAuth } from '@/middleware/withAuth'; import { hasEntitlement } from '@/lib/entitlements'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; -import { getMcpToolMetadata } from '@/ee/features/mcp/mcpToolMetadata'; +import { getMcpToolMetadata } from '@/ee/features/chat/mcp/mcpToolMetadata'; import type { NextRequest } from 'next/server'; export const GET = apiHandler(async (_request: NextRequest) => { diff --git a/packages/web/src/ee/features/mcp/actions.test.ts b/packages/web/src/ee/features/chat/mcp/actions.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/actions.test.ts rename to packages/web/src/ee/features/chat/mcp/actions.test.ts diff --git a/packages/web/src/ee/features/mcp/actions.ts b/packages/web/src/ee/features/chat/mcp/actions.ts similarity index 100% rename from packages/web/src/ee/features/mcp/actions.ts rename to packages/web/src/ee/features/chat/mcp/actions.ts diff --git a/packages/web/src/ee/features/mcp/analytics.ts b/packages/web/src/ee/features/chat/mcp/analytics.ts similarity index 100% rename from packages/web/src/ee/features/mcp/analytics.ts rename to packages/web/src/ee/features/chat/mcp/analytics.ts diff --git a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx b/packages/web/src/ee/features/chat/mcp/components/connectMcpButton.tsx similarity index 94% rename from packages/web/src/ee/features/mcp/components/connectMcpButton.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectMcpButton.tsx index c8969af14..53bff998e 100644 --- a/packages/web/src/ee/features/mcp/components/connectMcpButton.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectMcpButton.tsx @@ -3,7 +3,7 @@ import { LoadingButton } from '@/components/ui/loading-button'; import { ExternalLink } from 'lucide-react'; import type { ButtonProps } from '@/components/ui/button'; -import { useConnectMcp } from '@/ee/features/mcp/hooks/useConnectMcp'; +import { useConnectMcp } from '@/ee/features/chat/mcp/hooks/useConnectMcp'; interface ConnectMcpButtonProps { serverId: string; diff --git a/packages/web/src/ee/features/mcp/components/connectorCard.test.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorCard.test.tsx similarity index 99% rename from packages/web/src/ee/features/mcp/components/connectorCard.test.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorCard.test.tsx index 6a4f754aa..5e843d857 100644 --- a/packages/web/src/ee/features/mcp/components/connectorCard.test.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorCard.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { ConnectorCard } from './connectorCard'; -import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/chat/mcp/types'; afterEach(() => { cleanup(); diff --git a/packages/web/src/ee/features/mcp/components/connectorCard.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorCard.tsx similarity index 94% rename from packages/web/src/ee/features/mcp/components/connectorCard.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorCard.tsx index 349ae6511..3fc7feaf7 100644 --- a/packages/web/src/ee/features/mcp/components/connectorCard.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorCard.tsx @@ -2,10 +2,10 @@ import { useId, useState, type ReactNode } from 'react'; import { Card, CardContent } from '@/components/ui/card'; -import { ConnectorRowInfo } from '@/ee/features/mcp/components/connectorRowInfo'; -import { ConnectorToolList, ConnectorToolTrigger } from '@/ee/features/mcp/components/connectorToolDisclosure'; -import { ConnectorToolUsageList, ConnectorToolUsageTrigger } from '@/ee/features/mcp/components/connectorToolUsageDisclosure'; -import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; +import { ConnectorRowInfo } from '@/ee/features/chat/mcp/components/connectorRowInfo'; +import { ConnectorToolList, ConnectorToolTrigger } from '@/ee/features/chat/mcp/components/connectorToolDisclosure'; +import { ConnectorToolUsageList, ConnectorToolUsageTrigger } from '@/ee/features/chat/mcp/components/connectorToolUsageDisclosure'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/chat/mcp/types'; interface ConnectorCardProps { faviconUrl: string | undefined; diff --git a/packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorRowInfo.tsx similarity index 100% rename from packages/web/src/ee/features/mcp/components/connectorRowInfo.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorRowInfo.tsx diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.test.tsx similarity index 98% rename from packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.test.tsx index bd8f624f1..8fc018779 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.test.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { ConnectorToolList, ConnectorToolTrigger } from './connectorToolDisclosure'; -import type { ServerToolsEntry } from '@/ee/features/mcp/types'; +import type { ServerToolsEntry } from '@/ee/features/chat/mcp/types'; afterEach(() => { cleanup(); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.tsx similarity index 98% rename from packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.tsx index 562bc15c3..659fc9702 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolDisclosure.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorToolDisclosure.tsx @@ -3,8 +3,8 @@ import { useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; -import { pluralize } from '@/ee/features/mcp/utils'; -import type { ServerToolsEntry, ToolMetadataErrorReason, ToolSummary } from '@/ee/features/mcp/types'; +import { pluralize } from '@/ee/features/chat/mcp/utils'; +import type { ServerToolsEntry, ToolMetadataErrorReason, ToolSummary } from '@/ee/features/chat/mcp/types'; import { ChevronDownIcon, RefreshCwIcon, WrenchIcon } from 'lucide-react'; function getErrorLabel(reason: ToolMetadataErrorReason) { diff --git a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.test.tsx similarity index 98% rename from packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.test.tsx index 8e6793216..d81da94d5 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.test.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { ConnectorToolUsageList, ConnectorToolUsageTrigger } from './connectorToolUsageDisclosure'; -import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/chat/mcp/types'; afterEach(() => { cleanup(); diff --git a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.tsx similarity index 98% rename from packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx rename to packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.tsx index 6fc9e4f5f..3f4f78c53 100644 --- a/packages/web/src/ee/features/mcp/components/connectorToolUsageDisclosure.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorToolUsageDisclosure.tsx @@ -5,8 +5,8 @@ import { formatCount, formatUsageSharePercent, pluralize, -} from '@/ee/features/mcp/utils'; -import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/mcp/types'; +} from '@/ee/features/chat/mcp/utils'; +import type { McpServerToolUsageSummary, ServerToolsEntry } from '@/ee/features/chat/mcp/types'; import { BarChart3Icon, ChevronDownIcon } from 'lucide-react'; interface ConnectorToolUsageTriggerProps { diff --git a/packages/web/src/ee/features/mcp/components/mcpFavicon.tsx b/packages/web/src/ee/features/chat/mcp/components/mcpFavicon.tsx similarity index 100% rename from packages/web/src/ee/features/mcp/components/mcpFavicon.tsx rename to packages/web/src/ee/features/chat/mcp/components/mcpFavicon.tsx diff --git a/packages/web/src/ee/features/mcp/connectionStatus.test.ts b/packages/web/src/ee/features/chat/mcp/connectionStatus.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/connectionStatus.test.ts rename to packages/web/src/ee/features/chat/mcp/connectionStatus.test.ts diff --git a/packages/web/src/ee/features/mcp/connectionStatus.ts b/packages/web/src/ee/features/chat/mcp/connectionStatus.ts similarity index 100% rename from packages/web/src/ee/features/mcp/connectionStatus.ts rename to packages/web/src/ee/features/chat/mcp/connectionStatus.ts diff --git a/packages/web/src/ee/features/mcp/dcrDiscovery.test.ts b/packages/web/src/ee/features/chat/mcp/dcrDiscovery.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/dcrDiscovery.test.ts rename to packages/web/src/ee/features/chat/mcp/dcrDiscovery.test.ts diff --git a/packages/web/src/ee/features/mcp/dcrDiscovery.ts b/packages/web/src/ee/features/chat/mcp/dcrDiscovery.ts similarity index 100% rename from packages/web/src/ee/features/mcp/dcrDiscovery.ts rename to packages/web/src/ee/features/chat/mcp/dcrDiscovery.ts diff --git a/packages/web/src/ee/features/mcp/errors.ts b/packages/web/src/ee/features/chat/mcp/errors.ts similarity index 100% rename from packages/web/src/ee/features/mcp/errors.ts rename to packages/web/src/ee/features/chat/mcp/errors.ts diff --git a/packages/web/src/ee/features/mcp/externalMcpError.test.ts b/packages/web/src/ee/features/chat/mcp/externalMcpError.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/externalMcpError.test.ts rename to packages/web/src/ee/features/chat/mcp/externalMcpError.test.ts diff --git a/packages/web/src/ee/features/mcp/externalMcpError.ts b/packages/web/src/ee/features/chat/mcp/externalMcpError.ts similarity index 100% rename from packages/web/src/ee/features/mcp/externalMcpError.ts rename to packages/web/src/ee/features/chat/mcp/externalMcpError.ts diff --git a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts b/packages/web/src/ee/features/chat/mcp/hooks/useConnectMcp.ts similarity index 98% rename from packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts rename to packages/web/src/ee/features/chat/mcp/hooks/useConnectMcp.ts index e9e95ee20..6cb452c65 100644 --- a/packages/web/src/ee/features/mcp/hooks/useConnectMcp.ts +++ b/packages/web/src/ee/features/chat/mcp/hooks/useConnectMcp.ts @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useToast } from '@/components/hooks/use-toast'; import { useQueryClient } from '@tanstack/react-query'; import { connectMcpToAsk } from '@/app/api/(client)/client'; -import { invalidateMcpConfigurationQueries } from '@/ee/features/mcp/queryKeys'; +import { invalidateMcpConfigurationQueries } from '@/ee/features/chat/mcp/queryKeys'; import { isServiceError } from '@/lib/utils'; interface UseConnectMcpOptions { diff --git a/packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts b/packages/web/src/ee/features/chat/mcp/hooks/useMcpToolMetadata.ts similarity index 94% rename from packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts rename to packages/web/src/ee/features/chat/mcp/hooks/useMcpToolMetadata.ts index 92d0fa23e..b86eb66b5 100644 --- a/packages/web/src/ee/features/mcp/hooks/useMcpToolMetadata.ts +++ b/packages/web/src/ee/features/chat/mcp/hooks/useMcpToolMetadata.ts @@ -4,8 +4,8 @@ import { useEffect, useMemo, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getMcpServerTools } from '@/app/api/(client)/client'; import { isServiceError } from '@/lib/utils'; -import { mcpQueryKeys } from '@/ee/features/mcp/queryKeys'; -import type { ServerToolsEntry } from '@/ee/features/mcp/types'; +import { mcpQueryKeys } from '@/ee/features/chat/mcp/queryKeys'; +import type { ServerToolsEntry } from '@/ee/features/chat/mcp/types'; const EMPTY_TOOL_ENTRIES: ServerToolsEntry[] = []; diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.test.ts b/packages/web/src/ee/features/chat/mcp/mcpClientFactory.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpClientFactory.test.ts rename to packages/web/src/ee/features/chat/mcp/mcpClientFactory.test.ts diff --git a/packages/web/src/ee/features/mcp/mcpClientFactory.ts b/packages/web/src/ee/features/chat/mcp/mcpClientFactory.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpClientFactory.ts rename to packages/web/src/ee/features/chat/mcp/mcpClientFactory.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts b/packages/web/src/ee/features/chat/mcp/mcpToolMetadata.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolMetadata.test.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolMetadata.test.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolMetadata.ts b/packages/web/src/ee/features/chat/mcp/mcpToolMetadata.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolMetadata.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolMetadata.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts b/packages/web/src/ee/features/chat/mcp/mcpToolRegistry.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolRegistry.test.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolRegistry.test.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolRegistry.ts b/packages/web/src/ee/features/chat/mcp/mcpToolRegistry.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolRegistry.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolRegistry.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolSets.test.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts diff --git a/packages/web/src/ee/features/mcp/mcpToolSets.ts b/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts similarity index 100% rename from packages/web/src/ee/features/mcp/mcpToolSets.ts rename to packages/web/src/ee/features/chat/mcp/mcpToolSets.ts diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.test.ts b/packages/web/src/ee/features/chat/mcp/prefabMcpServers.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/prefabMcpServers.test.ts rename to packages/web/src/ee/features/chat/mcp/prefabMcpServers.test.ts diff --git a/packages/web/src/ee/features/mcp/prefabMcpServers.ts b/packages/web/src/ee/features/chat/mcp/prefabMcpServers.ts similarity index 100% rename from packages/web/src/ee/features/mcp/prefabMcpServers.ts rename to packages/web/src/ee/features/chat/mcp/prefabMcpServers.ts diff --git a/packages/web/src/ee/features/mcp/queryKeys.test.ts b/packages/web/src/ee/features/chat/mcp/queryKeys.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/queryKeys.test.ts rename to packages/web/src/ee/features/chat/mcp/queryKeys.test.ts diff --git a/packages/web/src/ee/features/mcp/queryKeys.ts b/packages/web/src/ee/features/chat/mcp/queryKeys.ts similarity index 100% rename from packages/web/src/ee/features/mcp/queryKeys.ts rename to packages/web/src/ee/features/chat/mcp/queryKeys.ts diff --git a/packages/web/src/ee/features/mcp/types.ts b/packages/web/src/ee/features/chat/mcp/types.ts similarity index 100% rename from packages/web/src/ee/features/mcp/types.ts rename to packages/web/src/ee/features/chat/mcp/types.ts diff --git a/packages/web/src/ee/features/mcp/utils.test.ts b/packages/web/src/ee/features/chat/mcp/utils.test.ts similarity index 100% rename from packages/web/src/ee/features/mcp/utils.test.ts rename to packages/web/src/ee/features/chat/mcp/utils.test.ts diff --git a/packages/web/src/ee/features/mcp/utils.ts b/packages/web/src/ee/features/chat/mcp/utils.ts similarity index 100% rename from packages/web/src/ee/features/mcp/utils.ts rename to packages/web/src/ee/features/chat/mcp/utils.ts diff --git a/packages/web/src/features/chat/agent.test.ts b/packages/web/src/features/chat/agent.test.ts index 1fdac1e6b..e7984e655 100644 --- a/packages/web/src/features/chat/agent.test.ts +++ b/packages/web/src/features/chat/agent.test.ts @@ -31,16 +31,16 @@ vi.mock('@sourcebot/shared', () => ({ vi.mock('server-only', () => ({})); -vi.mock('@/ee/features/mcp/mcpClientFactory', () => ({ +vi.mock('@/ee/features/chat/mcp/mcpClientFactory', () => ({ getConnectedMcpClients: vi.fn(), })); -vi.mock('@/ee/features/mcp/mcpToolRegistry', () => ({ +vi.mock('@/ee/features/chat/mcp/mcpToolRegistry', () => ({ buildMcpToolRegistry: vi.fn(() => []), searchMcpTools: vi.fn(() => []), })); -vi.mock('@/ee/features/mcp/mcpToolSets', () => ({ +vi.mock('@/ee/features/chat/mcp/mcpToolSets', () => ({ getMcpTools: vi.fn(), })); diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 625a5e76f..f4bd96854 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -22,9 +22,9 @@ import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString, getAnswerPartFromAssistantMessage, getTurnProgressState } from "./utils"; import { createTools } from "./tools"; -import { getConnectedMcpClients } from "@/ee/features/mcp/mcpClientFactory"; -import { getMcpTools, McpToolsResult } from "@/ee/features/mcp/mcpToolSets"; -import { buildMcpToolRegistry, McpToolRegistryEntry, searchMcpTools } from "@/ee/features/mcp/mcpToolRegistry"; +import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory"; +import { getMcpTools, McpToolsResult } from "@/ee/features/chat/mcp/mcpToolSets"; +import { buildMcpToolRegistry, McpToolRegistryEntry, searchMcpTools } from "@/ee/features/chat/mcp/mcpToolRegistry"; import { hasEntitlement } from '@/lib/entitlements'; const dedent = _dedent.withOptions({ alignValues: true }); diff --git a/packages/web/src/features/chat/askMcpAnalytics.server.ts b/packages/web/src/features/chat/askMcpAnalytics.server.ts index c799b4908..67e1ab6df 100644 --- a/packages/web/src/features/chat/askMcpAnalytics.server.ts +++ b/packages/web/src/features/chat/askMcpAnalytics.server.ts @@ -1,4 +1,4 @@ -import { getStoredMcpConnectionStatus } from "@/ee/features/mcp/connectionStatus"; +import { getStoredMcpConnectionStatus } from "@/ee/features/chat/mcp/connectionStatus"; import { hasEntitlement } from "@/lib/entitlements"; import type { PrismaClient } from "@sourcebot/db"; import type { DynamicToolUIPart } from "ai"; diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx index 5c776aa1e..1f09388e9 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxPlusButton.tsx @@ -14,8 +14,8 @@ import { import { Switch } from "@/components/ui/switch"; import { connectMcpToAsk, getMcpServersWithStatus } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; -import { mcpQueryKeys } from "@/ee/features/mcp/queryKeys"; +import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; +import { mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; import { isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx b/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx index 044795e60..636c951f9 100644 --- a/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx +++ b/packages/web/src/features/chat/components/chatThread/toolApprovalBanner.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button } from "@/components/ui/button"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; import { useMcpServerIconMap } from "@/features/chat/mcpServerIconContext"; import { useToolApproval } from "@/features/chat/toolApprovalContext"; import { SBChatToolPart } from "@/features/chat/utils"; diff --git a/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx index 3e679a21b..aeca09156 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/mcpToolComponent.tsx @@ -1,7 +1,7 @@ 'use client'; import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; -import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon"; +import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; import { useMcpServerIconMap } from "@/features/chat/mcpServerIconContext"; import { cn } from "@/lib/utils"; import { DynamicToolUIPart } from "ai"; From 71c2893b1fddab102325f58296c814a9485e47ec Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Thu, 28 May 2026 20:43:01 -0700 Subject: [PATCH 33/40] Simplify static MCP OAuth HTTPS guard --- .../src/ee/features/chat/mcp/actions.test.ts | 61 ++++++------------- .../web/src/ee/features/chat/mcp/actions.ts | 41 ++----------- 2 files changed, 25 insertions(+), 77 deletions(-) diff --git a/packages/web/src/ee/features/chat/mcp/actions.test.ts b/packages/web/src/ee/features/chat/mcp/actions.test.ts index 1aefc0da8..1009c3a4f 100644 --- a/packages/web/src/ee/features/chat/mcp/actions.test.ts +++ b/packages/web/src/ee/features/chat/mcp/actions.test.ts @@ -5,11 +5,6 @@ import { ErrorCode } from '@/lib/errorCodes'; const mocks = vi.hoisted(() => ({ authContext: undefined as unknown, hasEntitlement: vi.fn(), - headers: vi.fn(async () => new Headers({ - host: 'sourcebot.example.com', - origin: 'https://sourcebot.example.com', - 'x-forwarded-proto': 'https', - })), encryptOAuthToken: vi.fn((text: string | null | undefined) => text ? `encrypted:${text}` : undefined), env: { AUTH_URL: 'https://sourcebot.example.com', @@ -32,9 +27,6 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('server-only', () => ({})); -vi.mock('next/headers', () => ({ - headers: mocks.headers, -})); vi.mock('@/middleware/withAuth', () => ({ withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), })); @@ -97,11 +89,6 @@ function createStaticOAuthRequest(overrides: Partial<{ beforeEach(() => { vi.clearAllMocks(); mocks.hasEntitlement.mockResolvedValue(true); - mocks.headers.mockResolvedValue(new Headers({ - host: 'sourcebot.example.com', - origin: 'https://sourcebot.example.com', - 'x-forwarded-proto': 'https', - })); mocks.encryptOAuthToken.mockImplementation((text: string | null | undefined) => text ? `encrypted:${text}` : undefined); mocks.env.AUTH_URL = 'https://sourcebot.example.com'; mocks.env.NODE_ENV = 'production'; @@ -236,25 +223,6 @@ describe('createStaticOAuthMcpServer', () => { expect(JSON.stringify(result)).not.toContain('client-secret'); }); - test('rejects static OAuth credentials over insecure production requests', async () => { - const prisma = setAuthContext(OrgRole.OWNER); - mocks.headers.mockResolvedValue(new Headers({ - host: 'sourcebot.example.com', - origin: 'http://sourcebot.example.com', - 'x-forwarded-proto': 'http', - })); - - const result = await createStaticOAuthMcpServer(createStaticOAuthRequest()); - - expect(result).toMatchObject({ - statusCode: 400, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: 'Static OAuth client credentials require HTTPS in production.', - }); - expect(prisma.mcpServer.create).not.toHaveBeenCalled(); - expect(JSON.stringify(result)).not.toContain('client-secret'); - }); - test('does not echo client secrets in validation errors', async () => { const prisma = setAuthContext(OrgRole.OWNER); @@ -414,22 +382,29 @@ describe('deleteMcpServer', () => { describe('disconnectMcpServer', () => { test('disconnects a personal connector and tracks the disconnect', async () => { + const prisma = { + mcpServer: { + findFirst: vi.fn().mockResolvedValue({ + id: 'server-1', + name: 'Linear', + serverUrl: 'https://mcp.linear.app/mcp', + sanitizedName: 'linear', + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }), + }, + userMcpServer: { + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + }; mocks.authContext = { org: { id: 1 }, user: { id: 'user-1' }, + prisma, }; - mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue({ - id: 'server-1', - name: 'Linear', - serverUrl: 'https://mcp.linear.app/mcp', - sanitizedName: 'linear', - clientInfoSource: McpServerClientInfoSource.DYNAMIC, - }); - mocks.unsafePrisma.userMcpServer.deleteMany.mockResolvedValue({ count: 1 }); await expect(disconnectMcpServer('server-1', 'account_settings')).resolves.toEqual({ success: true }); - expect(mocks.unsafePrisma.mcpServer.findFirst).toHaveBeenCalledWith({ + expect(prisma.mcpServer.findFirst).toHaveBeenCalledWith({ where: { id: 'server-1', orgId: 1, @@ -442,12 +417,14 @@ describe('disconnectMcpServer', () => { clientInfoSource: true, }, }); - expect(mocks.unsafePrisma.userMcpServer.deleteMany).toHaveBeenCalledWith({ + expect(prisma.userMcpServer.deleteMany).toHaveBeenCalledWith({ where: { serverId: 'server-1', userId: 'user-1', }, }); + expect(mocks.unsafePrisma.mcpServer.findFirst).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.deleteMany).not.toHaveBeenCalled(); expect(mocks.captureEvent).toHaveBeenCalledWith('ask_mcp_connector_disconnected', { source: 'sourcebot-web-client', entryPoint: 'account_settings', diff --git a/packages/web/src/ee/features/chat/mcp/actions.ts b/packages/web/src/ee/features/chat/mcp/actions.ts index c1c3528e9..7ce05ede4 100644 --- a/packages/web/src/ee/features/chat/mcp/actions.ts +++ b/packages/web/src/ee/features/chat/mcp/actions.ts @@ -15,7 +15,6 @@ import { hasEntitlement } from '@/lib/entitlements'; import { oauthNotSupported } from './errors'; import { checkMcpServerDcrSupport } from './dcrDiscovery'; import { encryptOAuthToken, env } from '@sourcebot/shared'; -import { headers } from 'next/headers'; import { captureEvent } from '@/lib/posthog'; import { getMcpAuthMode } from './analytics'; import type { McpConnectorEntryPoint } from '@/lib/posthogEvents'; @@ -67,40 +66,12 @@ function invalidRequest(message: string): ServiceError { }; } -function getFirstHeaderValue(value: string | null): string | undefined { - return value?.split(',')[0]?.trim().toLowerCase(); -} - -function getHeaderUrlProtocol(value: string | null, host: string | undefined): string | undefined { - if (!value || !host) { - return undefined; - } - - try { - const url = new URL(value); - return url.host === host ? url.protocol : undefined; - } catch { - return undefined; - } -} - -async function assertHttpsInProduction(): Promise { +function assertHttpsAuthUrlInProduction(): ServiceError | undefined { if (env.NODE_ENV !== 'production') { return undefined; } - const requestHeaders = await headers(); - const publicAuthUrlIsHttps = new URL(env.AUTH_URL).protocol === 'https:'; - const host = getFirstHeaderValue(requestHeaders.get('x-forwarded-host')) - ?? getFirstHeaderValue(requestHeaders.get('host')); - const originProtocol = getHeaderUrlProtocol(requestHeaders.get('origin'), host); - const refererProtocol = getHeaderUrlProtocol(requestHeaders.get('referer'), host); - const requestIsHttps = getFirstHeaderValue(requestHeaders.get('x-forwarded-proto')) === 'https' - || getFirstHeaderValue(requestHeaders.get('x-forwarded-ssl')) === 'on' - || originProtocol === 'https:' - || refererProtocol === 'https:'; - - if (publicAuthUrlIsHttps && requestIsHttps) { + if (new URL(env.AUTH_URL).protocol === 'https:') { return undefined; } @@ -218,7 +189,7 @@ export const createStaticOAuthMcpServer = async ( return oauthNotSupported(); } - const httpsError = await assertHttpsInProduction(); + const httpsError = assertHttpsAuthUrlInProduction(); if (httpsError) { return httpsError; } @@ -343,8 +314,8 @@ export const deleteMcpServer = async (serverId: string) => sew(() => }))); export const disconnectMcpServer = async (serverId: string, entryPoint: McpConnectorEntryPoint) => sew(() => - withAuth(async ({ org, user }) => { - const server = await __unsafePrisma.mcpServer.findFirst({ + withAuth(async ({ org, user, prisma }) => { + const server = await prisma.mcpServer.findFirst({ where: { id: serverId, orgId: org.id, @@ -366,7 +337,7 @@ export const disconnectMcpServer = async (serverId: string, entryPoint: McpConne } satisfies ServiceError; } - const result = await __unsafePrisma.userMcpServer.deleteMany({ + const result = await prisma.userMcpServer.deleteMany({ where: { serverId, userId: user.id, From 9c1580772b75b41db19fe5042bf5a3fd47ab5e26 Mon Sep 17 00:00:00 2001 From: Michael Sukkarieh Date: Thu, 28 May 2026 20:28:00 -0700 Subject: [PATCH 34/40] docs: v5 docs updates (#1244) --- .../sourcebot-public.openapi.json | 8 +- docs/docs.json | 78 ++++-- docs/docs/activating-a-subscription.mdx | 39 +++ docs/docs/billing.mdx | 26 +- docs/docs/configuration/audit-logs.mdx | 4 +- .../auth/{overview.mdx => authentication.mdx} | 2 +- docs/docs/configuration/auth/providers.mdx | 2 +- docs/docs/configuration/config-file.mdx | 2 +- .../docs/configuration/declarative-config.mdx | 2 +- .../configuration/environment-variables.mdx | 6 +- docs/docs/configuration/idp.mdx | 4 +- docs/docs/connections/ado-cloud.mdx | 2 +- docs/docs/connections/ado-server.mdx | 2 +- docs/docs/connections/bitbucket-cloud.mdx | 2 +- .../connections/bitbucket-data-center.mdx | 2 +- docs/docs/connections/generic-git-host.mdx | 6 +- docs/docs/connections/gerrit.mdx | 2 +- docs/docs/connections/gitea.mdx | 2 +- docs/docs/connections/github.mdx | 6 +- docs/docs/connections/gitlab.mdx | 2 +- .../{overview.mdx => indexing-your-code.mdx} | 5 +- docs/docs/connections/local-repos.mdx | 4 +- docs/docs/deployment/docker-compose.mdx | 46 +++- .../infrastructure/architecture.mdx | 2 +- docs/docs/deployment/sizing-guide.mdx | 4 +- .../agents/{overview.mdx => agents.mdx} | 3 +- .../ask/{overview.mdx => ask-sourcebot.mdx} | 6 +- docs/docs/features/ask/chat-sharing.mdx | 4 +- docs/docs/features/mcp-server.mdx | 2 +- docs/docs/features/permission-syncing.mdx | 2 +- .../search/{overview.mdx => code-search.mdx} | 4 +- .../features/search/multi-branch-indexing.mdx | 2 +- docs/docs/features/search/search-contexts.mdx | 2 +- docs/docs/free-trial.mdx | 40 +++ docs/docs/license-key.mdx | 50 ---- docs/docs/misc/architecture.mdx | 19 ++ docs/docs/misc/scalability.mdx | 7 + docs/docs/misc/service-ping.mdx | 62 +++++ docs/docs/misc/telemetry.mdx | 22 ++ docs/docs/overview.mdx | 234 ++---------------- docs/docs/upgrade/v3-to-v4-guide.mdx | 4 +- docs/docs/upgrade/v4-to-v5-guide.mdx | 32 +++ docs/snippets/license-key-required.mdx | 4 +- packages/setupWizard/README.md | 2 +- packages/setupWizard/src/index.ts | 3 +- packages/setupWizard/src/models.ts | 2 +- .../(app)/chat/components/tutorialDialog.tsx | 2 +- .../banners/servicePingFailedBanner.tsx | 3 +- .../(app)/components/searchModeSelector.tsx | 4 +- .../app/(app)/settings/connections/page.tsx | 2 +- .../src/app/components/authMethodSelector.tsx | 2 +- .../src/app/onboard/components/trialStep.tsx | 14 +- packages/web/src/app/onboard/page.tsx | 2 +- .../web/src/ee/features/lighthouse/client.ts | 8 + .../lighthouse/planComparisonTable.tsx | 4 +- .../ee/features/lighthouse/upsellDialog.tsx | 15 +- packages/web/src/lib/constants.ts | 1 - packages/web/src/openapi/publicApiDocument.ts | 3 +- 58 files changed, 456 insertions(+), 370 deletions(-) create mode 100644 docs/docs/activating-a-subscription.mdx rename docs/docs/configuration/auth/{overview.mdx => authentication.mdx} (98%) rename docs/docs/connections/{overview.mdx => indexing-your-code.mdx} (93%) rename docs/docs/features/agents/{overview.mdx => agents.mdx} (91%) rename docs/docs/features/ask/{overview.mdx => ask-sourcebot.mdx} (92%) rename docs/docs/features/search/{overview.mdx => code-search.mdx} (93%) create mode 100644 docs/docs/free-trial.mdx delete mode 100644 docs/docs/license-key.mdx create mode 100644 docs/docs/misc/architecture.mdx create mode 100644 docs/docs/misc/scalability.mdx create mode 100644 docs/docs/misc/service-ping.mdx create mode 100644 docs/docs/misc/telemetry.mdx create mode 100644 docs/docs/upgrade/v4-to-v5-guide.mdx diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 4c6cd8bc6..44488d142 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -2289,7 +2289,7 @@ "summary": "Get a user", "description": "Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { @@ -2364,7 +2364,7 @@ "summary": "Delete a user", "description": "Permanently deletes a user and all associated records. Only organization owners can delete other users.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { @@ -2441,7 +2441,7 @@ "summary": "List users", "description": "Returns all members of the organization. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "responses": { "200": { @@ -2486,7 +2486,7 @@ "summary": "List audit records", "description": "Returns a paginated list of audit log entries. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { diff --git a/docs/docs.json b/docs/docs.json index 82679013a..94bcd37ad 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,18 +40,10 @@ "pages": [ "docs/overview", { - "group": "Deployment", + "group": "Deploy Sourcebot", "pages": [ "docs/deployment/docker-compose", - "docs/deployment/k8s", - "docs/deployment/sizing-guide", - { - "group": "Infrastructure", - "pages": [ - "docs/deployment/infrastructure/architecture", - "docs/deployment/infrastructure/redis" - ] - } + "docs/deployment/k8s" ] } ] @@ -61,8 +53,8 @@ "pages": [ { "group": "Code Search", + "root": "docs/features/search/code-search", "pages": [ - "docs/features/search/overview", "docs/features/search/syntax-reference", "docs/features/search/ai-search-assist", "docs/features/search/multi-branch-indexing", @@ -71,8 +63,8 @@ }, { "group": "Ask Sourcebot", + "root": "docs/features/ask/ask-sourcebot", "pages": [ - "docs/features/ask/overview", "docs/features/ask/chat-sharing", "docs/features/ask/add-model-providers" ] @@ -84,8 +76,8 @@ { "group": "Agents", "tag": "experimental", + "root": "docs/features/agents/agents", "pages": [ - "docs/features/agents/overview", "docs/features/agents/review-agent" ] } @@ -98,8 +90,8 @@ "docs/configuration/environment-variables", { "group": "Indexing your code", + "root": "docs/connections/indexing-your-code", "pages": [ - "docs/connections/overview", "docs/connections/github", "docs/connections/gitlab", "docs/connections/bitbucket-cloud", @@ -117,24 +109,48 @@ "docs/configuration/idp", { "group": "Authentication", + "root": "docs/configuration/auth/authentication", "pages": [ - "docs/configuration/auth/overview", "docs/configuration/auth/providers", "docs/configuration/auth/access-settings", "docs/configuration/auth/roles-and-permissions", "docs/configuration/auth/faq" ] }, - "docs/license-key", - "docs/billing", "docs/configuration/transactional-emails", "docs/configuration/structured-logging", "docs/configuration/audit-logs" ] }, + { + "group": "Subscribe", + "pages": [ + "docs/activating-a-subscription", + "docs/billing", + "docs/free-trial" + ] + }, + { + "group": "Misc", + "pages": [ + "docs/misc/architecture", + "docs/misc/scalability", + "docs/misc/telemetry", + "docs/misc/service-ping", + "docs/deployment/sizing-guide", + { + "group": "Infrastructure", + "pages": [ + "docs/deployment/infrastructure/architecture", + "docs/deployment/infrastructure/redis" + ] + } + ] + }, { "group": "Upgrade", "pages": [ + "docs/upgrade/v4-to-v5-guide", "docs/upgrade/v3-to-v4-guide", "docs/upgrade/v2-to-v3-guide" ] @@ -231,5 +247,31 @@ "appearance": { "default": "dark", "strict": false - } + }, + "redirects": [ + { + "source": "/docs/features/search/overview", + "destination": "/docs/features/search/code-search" + }, + { + "source": "/docs/features/ask/overview", + "destination": "/docs/features/ask/ask-sourcebot" + }, + { + "source": "/docs/features/agents/overview", + "destination": "/docs/features/agents/agents" + }, + { + "source": "/docs/connections/overview", + "destination": "/docs/connections/indexing-your-code" + }, + { + "source": "/docs/configuration/auth/overview", + "destination": "/docs/configuration/auth/authentication" + }, + { + "source": "/docs/license-key", + "destination": "/docs/activating-a-subscription" + } + ] } diff --git a/docs/docs/activating-a-subscription.mdx b/docs/docs/activating-a-subscription.mdx new file mode 100644 index 000000000..01eb010bf --- /dev/null +++ b/docs/docs/activating-a-subscription.mdx @@ -0,0 +1,39 @@ +--- +title: Activating a Subscription +sidebarTitle: Activating a subscription +--- + +You can activate a paid plan subscription by using either an Activation Code or a License Key. + +# Activation Code (Online Licensing) + +Your Sourcebot deployment must be able to send a [Service Ping](/docs/misc/service-ping) to validate your Activation Code. If your deployment is unable to send a service ping for 7 days it will downgrade to the free plan until a successul ping is sent. + +The default mechanism for activating a subscription is through an Activation Code. This activation mechanism allows your Sourcebot deployment to automatically manage its subscription by regularly +sychronizing with our license server. + +### What data does Sourcebot collect? + +To enable online licensing, your Sourcebot deployment must be able to communicate with our license server through our [Service Ping](/docs/misc/service-ping). The data that is transmitted is limited to information that is +required to administer your Sourcebot license and support dynamic seat increases. No sensitive data (source code, AI inputs/outputs, credentials, or user information) will ever be transmitted. For more information, please check out our [privacy policy](https://www.sourcebot.dev/privacy) + +# License Key (Offline Licensing) + +The default mechanism for activating a subscription is now through an [Activation Code](#activation-code-online-licensing). Offline licensing using a License Key may be requested by reaching out using our enterprise contact form on our [pricing page](https://www.sourcebot.dev/pricing) + +After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. + +If you'd like to increase the number of seats on your Sourcebot deployment you must request a new license key, manually update this environment variable, and then restart your deployment. + +```bash +docker run \ + -e SOURCEBOT_EE_LICENSE_KEY= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +## FAQ + +#### What happens when my subscription expires? + +When a paid plan subscription expires, your deployment will automatically be downgraded to the free plan. You must upgrade your subscription in order to continue using the paid features. \ No newline at end of file diff --git a/docs/docs/billing.mdx b/docs/docs/billing.mdx index 96cd0c120..7e4c6b860 100644 --- a/docs/docs/billing.mdx +++ b/docs/docs/billing.mdx @@ -1,23 +1,27 @@ --- -title: Billing -sidebarTitle: Billing +title: Seat Reconciliation +sidebarTitle: Seat Reconciliation --- -Sourcebot Enterprise is available on monthly and yearly plans. Both are seat-based. This page explains how seats are billed, how changes mid-term are handled, and what happens at renewal. +When using an [Activation Code](/docs/activating-a-subscription#activation-code-online-licensing), your Sourcebot deployment is permitted to exceed the number of seats you've purchsed. Seat reconciliation refers +to how these excess seats are billed, and it depends on your billing period. -## Seat count -Your seat count is the number of active users in your Sourcebot instance. Seat usage is reported to Sourcebot on a daily interval, and your subscription quantity is kept in sync with that number. +Seat reconciliation doesn't apply if you're using a [License Key](/docs/activating-a-subscription#license-key-offline-licensing) since the key sets a hard cap on seats. If you're using a license key and would like more seats, please reach out to us to purchase a new license key. -## Monthly plans -Monthly subscriptions are billed at the start of each billing cycle. Users added mid-cycle are prorated across the remaining days and appear on your next invoice. Users removed mid-cycle take effect at the next cycle. There is no refund for the remainder of the current one. +For a more detailed breakdown, please refer to our [Software License Agreeement](https://www.sourcebot.dev/terms) which is the source of truth on how seats are handled and billed. -In short: you can scale up at any time and pay the prorated difference. Scaling down is effectively free until the cycle rolls over. +# Monthly Billing -## Yearly plans +Sourcebot supports monthly with standard proration behavior. Users added during the month are prorated across the remaining days and appear on your next invoice. Users removed during the month take effect at the next cycle, and are not refunded for the current cycle. + +# Yearly Billing + +## Quarterly Reconciliation +The standard mechanism for seat reconciliation in yearly plans is through quarterly reconciliation. You are billed per quarter on a prorated basis for the remaining portion of your subscription term for any additional seats you activate. + +To prevent overages, you can restrict you may register on your Sourcebot deployment through our [access controls](/docs/configuration/auth/access-settings). -Yearly subscriptions are billed upfront for a committed seat count. As users are added during the term, the seat count rises but you aren't charged immediately. Every three months we reconcile. Any seats added that quarter are billed, prorated across the quarters remaining in the term. -Seats only move upward during the term. Shrinking the user count does not refund, and does not reduce the seat count until renewal. At renewal, you're invoiced at your current seat count, and that number becomes the committed baseline for the next year. ### Example diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index aed74bcac..1db8f6a66 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -5,7 +5,7 @@ sidebarTitle: Audit logs import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + Audit logs are a collection of notable events performed by users within a Sourcebot deployment. Each audit log records information on the action taken, the user who performed the action, and when the action took place. @@ -19,7 +19,7 @@ Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_A By default, audit logs older than 180 days are automatically pruned daily. You can configure the retention period using the `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` [environment variable](/docs/configuration/environment-variables). Set it to `0` to disable automatic pruning and retain logs indefinitely. ## Fetching Audit Logs -Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: +Audit logs are stored in the [postgres database](/docs/misc/architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: ```bash icon="terminal" Fetch audit logs curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/authentication.mdx similarity index 98% rename from docs/docs/configuration/auth/overview.mdx rename to docs/docs/configuration/auth/authentication.mdx index 117eed4f6..53e6c9e0e 100644 --- a/docs/docs/configuration/auth/overview.mdx +++ b/docs/docs/configuration/auth/authentication.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Authentication --- If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable. diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx index 5a7638bee..61e682a09 100644 --- a/docs/docs/configuration/auth/providers.mdx +++ b/docs/docs/configuration/auth/providers.mdx @@ -27,4 +27,4 @@ See [transactional emails](/docs/configuration/transactional-emails) for more de # Enterprise Authentication Providers Sourcebot supports authentication using several different [external identity providers](/docs/configuration/idp) as well. These identity providers require an -[enterprise license](/docs/license-key) \ No newline at end of file +[enterprise license](/docs/activating-a-subscription) \ No newline at end of file diff --git a/docs/docs/configuration/config-file.mdx b/docs/docs/configuration/config-file.mdx index 4e63cde85..d6162451d 100644 --- a/docs/docs/configuration/config-file.mdx +++ b/docs/docs/configuration/config-file.mdx @@ -23,7 +23,7 @@ The config file tells Sourcebot which repos to index, what language models to us The config file you provide Sourcebot must follow the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json). This schema consists of the following properties: -- [Connections](/docs/connections/overview) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where +- [Connections](/docs/connections/indexing-your-code) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where - [Language Models](/docs/configuration/language-model-providers) (`models`): Defines a set of language model providers for use with [Ask Sourcebot](/docs/features/ask) - [Settings](#settings) (`settings`): Additional settings to tweak your Sourcebot deployment - [Search Contexts](/docs/features/search/search-contexts) (`contexts`): Groupings of repos that you can search against diff --git a/docs/docs/configuration/declarative-config.mdx b/docs/docs/configuration/declarative-config.mdx index cdfdb4458..f998320b6 100644 --- a/docs/docs/configuration/declarative-config.mdx +++ b/docs/docs/configuration/declarative-config.mdx @@ -5,7 +5,7 @@ sidebarTitle: Declarative config import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' -Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview). +Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/indexing-your-code). | Variable | Description | diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 875a32dbe..396c4cf36 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -10,8 +10,8 @@ The following environment variables allow you to configure your Sourcebot deploy | Variable | Default | Description | | :------- | :------ | :---------- | -| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| -| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| +| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/authentication) for more info

| +| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/authentication) for more info

| | `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` |

Used to validate login session cookies

| | `AUTH_SESSION_MAX_AGE_SECONDS` | `2592000` (30 days) |

Relative time from now in seconds when to expire the session.

| | `AUTH_SESSION_UPDATE_AGE_SECONDS` | `86400` (1 day) |

How often the session should be updated in seconds. If set to `0`, session is updated every time.

| @@ -43,7 +43,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| | `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` |

Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.

| | `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - |

Optional file to log to if structured logging is enabled

| -| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/overview#telemetry) for more info.

| +| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/misc/telemetry) for more info.

| | `DEFAULT_MAX_MATCH_COUNT` | `10000` |

The default maximum number of search results to return when using search in the web app.

| | `ALWAYS_INDEX_FILE_PATTERNS` | - |

A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.

| | `NODE_USE_ENV_PROXY` | `0` |

Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.

| diff --git a/docs/docs/configuration/idp.mdx b/docs/docs/configuration/idp.mdx index 6126277fd..2496fc06e 100644 --- a/docs/docs/configuration/idp.mdx +++ b/docs/docs/configuration/idp.mdx @@ -5,7 +5,7 @@ sidebarTitle: External identity providers import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + You can connect Sourcebot to various **external identity providers** to associate a Sourcebot user with one or more external service accounts (ex. Google, GitHub, etc). @@ -90,7 +90,7 @@ in the GitHub identity provider config. Metadata repository permission set to Read-only - - `"Contents" repository permissions (read)` (only needed if using the app to [authenticate a connection](/docs/connections/github#github-app)) + - `"Contents" repository permissions (read)` (only needed if using the app to [authenticate a connection](/docs/connections/github#authenticating-with-github)) Contents repository permission set to Read-only diff --git a/docs/docs/connections/ado-cloud.mdx b/docs/docs/connections/ado-cloud.mdx index b1d12d5e3..6808817b3 100644 --- a/docs/docs/connections/ado-cloud.mdx +++ b/docs/docs/connections/ado-cloud.mdx @@ -6,7 +6,7 @@ icon: https://www.svgrepo.com/show/448307/azure-devops.svg import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx' -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/ado-server.mdx b/docs/docs/connections/ado-server.mdx index 09a592e7f..61d243e54 100644 --- a/docs/docs/connections/ado-server.mdx +++ b/docs/docs/connections/ado-server.mdx @@ -6,7 +6,7 @@ icon: https://www.svgrepo.com/show/448307/azure-devops.svg import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx' -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx index dfa620565..755b4f85f 100644 --- a/docs/docs/connections/bitbucket-cloud.mdx +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -10,7 +10,7 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' Looking for docs on Bitbucket Data Center? See [this doc](/docs/connections/bitbucket-data-center). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index be536e9e9..c14e81bcc 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -10,7 +10,7 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' Looking for docs on Bitbucket Cloud? See [this doc](/docs/connections/bitbucket-cloud). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/generic-git-host.mdx b/docs/docs/connections/generic-git-host.mdx index 4ebf363b6..585bf0eb3 100644 --- a/docs/docs/connections/generic-git-host.mdx +++ b/docs/docs/connections/generic-git-host.mdx @@ -5,13 +5,13 @@ icon: git-alt import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' -Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/overview#platform-connection-guides). +Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/indexing-your-code#platform-connection-guides). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Getting Started -To connect to a Git host, create a new [connection](/docs/connections/overview) with type `git` and specify the clone url in the `url` property. For example: +To connect to a Git host, create a new [connection](/docs/connections/indexing-your-code) with type `git` and specify the clone url in the `url` property. For example: ```json { diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index ae90104ee..0db4dace3 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -10,7 +10,7 @@ import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' Sourcebot can sync code from self-hosted gerrit instances. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Connecting to a Gerrit instance diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx index 1c589c413..b21e35d51 100644 --- a/docs/docs/connections/gitea.mdx +++ b/docs/docs/connections/gitea.mdx @@ -8,7 +8,7 @@ import GiteaSchema from '/snippets/schemas/v3/gitea.schema.mdx' Sourcebot can sync code from Gitea Cloud, and self-hosted. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx index 5f03fef96..fd5fbbb42 100644 --- a/docs/docs/connections/github.mdx +++ b/docs/docs/connections/github.mdx @@ -9,7 +9,7 @@ import LicenseKeyRequired from '/snippets/license-key-required.mdx' Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub Enterprise Cloud. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples @@ -161,7 +161,7 @@ In order to index private repositories, you'll need to authenticate with GitHub. [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) - Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/overview) config object. + Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/indexing-your-code) config object. The most common mechanism of doing this is defining an environment variable that holds the PAT: @@ -195,7 +195,7 @@ In order to index private repositories, you'll need to authenticate with GitHub. [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic) - Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/overview) config object. + Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/indexing-your-code) config object. The most common mechanism of doing this is defining an environment variable that holds the PAT: diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx index d8013d83a..94b8d78da 100644 --- a/docs/docs/connections/gitlab.mdx +++ b/docs/docs/connections/gitlab.mdx @@ -8,7 +8,7 @@ import GitLabSchema from '/snippets/schemas/v3/gitlab.schema.mdx' Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/indexing-your-code.mdx similarity index 93% rename from docs/docs/connections/overview.mdx rename to docs/docs/connections/indexing-your-code.mdx index e244debeb..d9f851cf7 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/indexing-your-code.mdx @@ -1,6 +1,5 @@ --- -title: Overview -sidebarTitle: Overview +title: Indexing your code --- import SupportedPlatforms from '/snippets/platform-support.mdx' @@ -48,7 +47,7 @@ When a connection is first discovered, or the `resyncConnectionIntervalMs` [sett 1. Fetching the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host. 2. Re-indexing the repository. -This is processed in a [job queue](/docs/overview#architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large. +This is processed in a [job queue](/docs/misc/architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large. On the home page, you can view the sync status of ongoing jobs: diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx index 62b76c692..0be9a647b 100644 --- a/docs/docs/connections/local-repos.mdx +++ b/docs/docs/connections/local-repos.mdx @@ -7,7 +7,7 @@ import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Getting Started @@ -40,7 +40,7 @@ To get Sourcebot to index these repositories: - We can now create a new git [connection](/docs/connections/overview), specifying local paths with the `file://` prefix. Glob patterns are supported. For example: + We can now create a new git [connection](/docs/connections/indexing-your-code), specifying local paths with the `file://` prefix. Glob patterns are supported. For example: ```json { diff --git a/docs/docs/deployment/docker-compose.mdx b/docs/docs/deployment/docker-compose.mdx index 8362bbeda..7899055a7 100644 --- a/docs/docs/deployment/docker-compose.mdx +++ b/docs/docs/deployment/docker-compose.mdx @@ -2,18 +2,23 @@ title: "Docker Compose" --- -This guide will walk you through deploying Sourcebot locally or on a VM using [Docker Compose](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml). This is the simplest way to get started with Sourcebot. +This guide will teach you how to deploy Sourcebot using [Docker Compose](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml). This is the simplest way to get started with Sourcebot, and should only take a couple minutes. If you are looking to deploy onto Kubernetes, see the [Kubernetes (Helm)](/docs/deployment/k8s) guide. ## System requirements -- RAM: Ensure your environment has at least 4GB of RAM. Insufficient memory can cause processes to crash. +- RAM: Ensure your environment has at least 4GB of RAM. Insufficient memory may cause your Sourcebot deployment to be unstable. - Docker & Docker Compose: Make sure both are installed and up-to-date. - Node.js 18+: Required for the setup CLI +For a more detailed requirements breakdown, please check out our [sizing guide](/docs/deployment/sizing-guide). + ## Option 1: Setup CLI -The setup CLI will guide you through configuring your Sourcebot instance to connect to your code hosts and LLM providers. From a empty folder, run the following command: +The setup CLI will guide you through deploying Sourcebot and connecting it to your code hosts and LLM providers. This will create a new directory and generate a [configuration file](/docs/configuration/config-file) +and Docker Compose file for you. + +Run the following command to get started: ``` npx setup-sourcebot @@ -35,7 +40,7 @@ curl -o docker-compose.yml https://raw.githubusercontent.com/sourcebot-dev/sourc ### Create a config.json -In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including what repositories to index, language model providers, auth providers, and more. +In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including which repos to index, what language models to use, and more. ```bash wrap icon="terminal" Create example config touch config.json @@ -67,4 +72,35 @@ Navigate to [http://localhost:3000](http://localhost:3000) to access your Source ## Next steps - +Congrats, you've deployed Sourcebot! Check out the following guides to learn more. + +#### Configuration + + + + Connect Sourcebot to your code hosts and configure which repos to index. + + + Configure the LLM providers that power Ask Sourcebot. + + + Set up SSO and configure how users sign in to your instance. + + + Right-size your deployment based on the number of repos you're indexing. + + + +#### Features + + + + Search and navigate across all your repos and branches. + + + Ask questions across all your repos and branches. + + + Code context layer for all your agents. + + diff --git a/docs/docs/deployment/infrastructure/architecture.mdx b/docs/docs/deployment/infrastructure/architecture.mdx index 023d173a0..ba58e0960 100644 --- a/docs/docs/deployment/infrastructure/architecture.mdx +++ b/docs/docs/deployment/infrastructure/architecture.mdx @@ -1,5 +1,5 @@ --- title: Architecture Overview -url: /docs/overview#architecture +url: /docs/misc/architecture sidebarTitle: Architecture Overview --- diff --git a/docs/docs/deployment/sizing-guide.mdx b/docs/docs/deployment/sizing-guide.mdx index 28f26406b..ebe772ad3 100644 --- a/docs/docs/deployment/sizing-guide.mdx +++ b/docs/docs/deployment/sizing-guide.mdx @@ -5,7 +5,7 @@ title: "Sizing Guide" Sourcebot runs as a single container (vertical scaling). This guide helps you choose the right CPU, memory, and disk allocation based on the number of repositories you plan to index. -These recommendations are based on real-world deployments. Your results may vary depending on repository sizes, search patterns, and whether you use features like [multi-branch indexing](/docs/features/search/multi-branch-indexing) or [Ask Sourcebot](/docs/features/ask/overview). +These recommendations are based on real-world deployments. Your results may vary depending on repository sizes, search patterns, and whether you use features like [multi-branch indexing](/docs/features/search/multi-branch-indexing) or [Ask Sourcebot](/docs/features/ask/ask-sourcebot). ## Recommendations @@ -48,7 +48,7 @@ Lowering these values reduces peak resource usage at the cost of slower initial ## Audit log storage -Audit logging is an enterprise feature and is only available with an [enterprise license](/docs/overview#license-key). If you are not on an enterprise plan, audit logs are not stored and this section does not apply. +Audit logging is an enterprise feature and is only available with an [enterprise license](/docs/activating-a-subscription). If you are not on an enterprise plan, audit logs are not stored and this section does not apply. [Audit logs](/docs/configuration/audit-logs) are stored in the Postgres database connected to your Sourcebot deployment. Each audit record captures the action performed, the actor, the target, a timestamp, and optional metadata (e.g., request source). There are three database indexes on the audit table to support analytics and lookup queries. diff --git a/docs/docs/features/agents/overview.mdx b/docs/docs/features/agents/agents.mdx similarity index 91% rename from docs/docs/features/agents/overview.mdx rename to docs/docs/features/agents/agents.mdx index 5b3bea6f5..c14877e09 100644 --- a/docs/docs/features/agents/overview.mdx +++ b/docs/docs/features/agents/agents.mdx @@ -1,6 +1,5 @@ --- -title: "Agents Overview" -sidebarTitle: "Overview" +title: "Agents" --- import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx' diff --git a/docs/docs/features/ask/overview.mdx b/docs/docs/features/ask/ask-sourcebot.mdx similarity index 92% rename from docs/docs/features/ask/overview.mdx rename to docs/docs/features/ask/ask-sourcebot.mdx index 79caf6283..3fcf81576 100644 --- a/docs/docs/features/ask/overview.mdx +++ b/docs/docs/features/ask/ask-sourcebot.mdx @@ -1,10 +1,10 @@ --- -title: Overview +title: Ask Sourcebot --- Ask Sourcebot gives you the ability to ask complex questions about your entire codebase in natural language. -It uses Sourcebot’s existing [code search](/docs/features/search/overview) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code, +It uses Sourcebot’s existing [code search](/docs/features/search/code-search) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code, follow code nav references, and provide an answer that’s rich with inline citations and navigable code snippets. Ask Sourcebot **uses an LLM provider you configure with Sourcebot**, ensuring you have full control over where your data is sent. @@ -19,7 +19,7 @@ Ask Sourcebot **uses an LLM provider you configure with Sourcebot**, ensuring yo Learn how to connect your language model to Sourcebot - + Learn how to index your repos so you can ask questions about them diff --git a/docs/docs/features/ask/chat-sharing.mdx b/docs/docs/features/ask/chat-sharing.mdx index bb60d8bed..e99f2a018 100644 --- a/docs/docs/features/ask/chat-sharing.mdx +++ b/docs/docs/features/ask/chat-sharing.mdx @@ -19,7 +19,7 @@ Every chat has a visibility setting that controls who can access it: ### Private (Default) - Only the chat owner can view the conversation - Other users cannot access the chat, even with the link -- You can explicitly invite specific org members to view the chat (requires [Enterprise license](/docs/license-key)) +- You can explicitly invite specific org members to view the chat (requires [Enterprise license](/docs/activating-a-subscription)) ### Public - Anyone with the link can view the conversation @@ -29,7 +29,7 @@ Every chat has a visibility setting that controls who can access it: ## Sharing with Specific Users -Sharing with specific users requires an [Enterprise license](/docs/license-key). +Sharing with specific users requires an [Enterprise License](/docs/activating-a-subscription). Invite users dialog diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx index ff0d2be26..1cb24b0d9 100644 --- a/docs/docs/features/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -16,7 +16,7 @@ The Sourcebot MCP Server connects AI tools to your [Sourcebot deployment](/docs/ Sourcebot MCP uses a [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) transport hosted at the `/api/mcp` route. Two authorization mechanisms are supported: -- **OAuth (preferred)**: MCP clients that support OAuth 2.0 will automatically handle the authorization flow and issue a short lived access token. No API key or manual token management required. Only available with an active [Enterprise license](/docs/license-key). +- **OAuth (preferred)**: MCP clients that support OAuth 2.0 will automatically handle the authorization flow and issue a short lived access token. No API key or manual token management required. Only available with an active [Enterprise license](/docs/activating-a-subscription). - **API key**: Any MCP client can authorize using a Sourcebot API key passed as a `Authorization: Bearer ` header. Create one in **Settings → API Keys**. You can read more about the options in the [authorization](#authorization) section. diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index 2f261edb3..268e2bb6d 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -147,7 +147,7 @@ The button will show a spinner while the sync is in progress and display a confi # Overriding enforcement per connection -Each [connection](/docs/connections/overview) supports two flags that control permission enforcement for that connection's repositories: +Each [connection](/docs/connections/indexing-your-code) supports two flags that control permission enforcement for that connection's repositories: - **`enforcePermissions`**: Controls whether repository permissions are enforced for the connection. When `PERMISSION_SYNC_ENABLED` is false, this setting has no effect. Defaults to the value of `PERMISSION_SYNC_ENABLED`. - **`enforcePermissionsForPublicRepos`**: Controls whether repository permissions are enforced for public repositories in the connection. When true, public repositories are only visible to users with a linked account for the connection's code host. When false, public repositories are visible to all users. Has no effect when `enforcePermissions` is false. Defaults to false. diff --git a/docs/docs/features/search/overview.mdx b/docs/docs/features/search/code-search.mdx similarity index 93% rename from docs/docs/features/search/overview.mdx rename to docs/docs/features/search/code-search.mdx index f9947ac96..9a1437dce 100644 --- a/docs/docs/features/search/overview.mdx +++ b/docs/docs/features/search/code-search.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Code Search --- Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. @@ -19,7 +19,7 @@ Search across all your repos/branches across any code host platform. Blazingly f Learn how to self-host Sourcebot in a few simple steps. - + Learn how to index your repos so you can ask questions about them diff --git a/docs/docs/features/search/multi-branch-indexing.mdx b/docs/docs/features/search/multi-branch-indexing.mdx index aad655915..9857ed3c0 100644 --- a/docs/docs/features/search/multi-branch-indexing.mdx +++ b/docs/docs/features/search/multi-branch-indexing.mdx @@ -15,7 +15,7 @@ By default, only the default branch of a repository is indexed and can be search Multi-branch indexing is currently limited to 64 branches and tags. Please see [this issue](https://github.com/sourcebot-dev/sourcebot/issues/461) for more details. -Multi-branch indexing is configured in the [connection](/docs/connections/overview) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: +Multi-branch indexing is configured in the [connection](/docs/connections/indexing-your-code) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: ```json { diff --git a/docs/docs/features/search/search-contexts.mdx b/docs/docs/features/search/search-contexts.mdx index 3fc17fa79..60aed4070 100644 --- a/docs/docs/features/search/search-contexts.mdx +++ b/docs/docs/features/search/search-contexts.mdx @@ -6,7 +6,7 @@ sidebarTitle: Search contexts import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. Some example queries using search contexts: diff --git a/docs/docs/free-trial.mdx b/docs/docs/free-trial.mdx new file mode 100644 index 000000000..ddf54b6a6 --- /dev/null +++ b/docs/docs/free-trial.mdx @@ -0,0 +1,40 @@ +--- +title: Free Trial +sidebarTitle: Free trial +--- + +Sourcebot offers a **14-day free trial** of the paid plan with no credit card required. The trial is started from inside your Sourcebot deployment, so you need to deploy Sourcebot first. + + + If your organization requires a license before deploying any software, reach out through the enterprise contact form on our [pricing page](https://www.sourcebot.dev/pricing). + + +# Deploy Sourcebot for free + +Sourcebot has a **free plan** that doesn't require any registration. You can self-host Sourcebot in your own infrastructure in under a minute: + + + + The fastest way to get Sourcebot running locally or on a server. + + + Deploy Sourcebot to a Kubernetes cluster with Helm. + + + +# Start your trial + +Your Sourcebot deployment must be able to send a [Service Ping](/docs/misc/service-ping) to activate and maintain your trial. If your deployment is unable to send a service ping for 7 days it will downgrade to the free plan until a successful ping is sent. + +Once your deployment is running, sign in and follow the onboarding flow. You'll see a **Start 14-day free trial** option for Sourcebot Pro. Confirm your email on the checkout page and submit. No credit card is required. + +If you skip the trial during onboarding, you can start it later from **Settings → License**. + +Each Sourcebot deployment is eligible for one free trial. + +# After the trial ends + +- If you add a payment method during the trial, your subscription continues automatically when the 14 days are up. +- If you don't add a payment method, your deployment downgrades to the free plan. Your data is preserved and free features remain available. + +You can add a payment method anytime during the trial from **Settings → License**. For details on how the paid plan is activated and validated, see [Activating a Subscription](/docs/activating-a-subscription). diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx deleted file mode 100644 index ecc4eebda..000000000 --- a/docs/docs/license-key.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: License key -sidebarTitle: License key ---- - - -If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours - - -All core Sourcebot features are available under the [FSL license](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license). Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details. - - -## Activating a license key ---- - -After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. - -```bash -docker run \ - -e SOURCEBOT_EE_LICENSE_KEY= \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -## Feature availability ---- - -| Feature | [FSL](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) | [Enterprise](https://github.com/sourcebot-dev/sourcebot/blob/main/ee/LICENSE) | -|:---------|:-----|:----------| -| [Search](/docs/features/search/syntax-reference) | ✅ | ✅ | -| [Full code host support](/docs/connections/overview) | ✅ | ✅ | -| [MCP Server](/docs/features/mcp-server) | ✅ | ✅ | -| [Agents](/docs/features/agents/overview) | ✅ | ✅ | -| [Login with credentials](/docs/configuration/auth/overview) | ✅ | ✅ | -| [Login with email codes](/docs/configuration/auth/overview) | ✅ | ✅ | -| [Login with SSO](/docs/configuration/auth/providers#enterprise-authentication-providers) | 🛑 | ✅ | -| [Permission syncing](/docs/features/permission-syncing) | 🛑 | ✅ | -| [Code navigation](/docs/features/code-navigation) | 🛑 | ✅ | -| [Search contexts](/docs/features/search/search-contexts) | 🛑 | ✅ | -| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ | -| [Analytics](/docs/features/analytics) | 🛑 | ✅ | -| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ | -| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ | - - -## Questions? - -For how seats are priced and reconciled across billing cycles, see [Billing](/docs/billing). - -For any other licensing questions, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/docs/docs/misc/architecture.mdx b/docs/docs/misc/architecture.mdx new file mode 100644 index 000000000..ad496f6ae --- /dev/null +++ b/docs/docs/misc/architecture.mdx @@ -0,0 +1,19 @@ +--- +title: "Architecture" +--- + +Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): + +![architecture diagram](/images/architecture_diagram.png) + +{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} + +Sourcebot consists of the following components: +- **Web Server** : main Next.js web application serving the Sourcebot UI. +- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. +- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. +- **Postgres** : transactional database for storing business-logic data. +- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. +- **`.sourcebot/` cache** : file-system cache where persistent data is written. + +You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [environment variables](/docs/configuration/environment-variables) doc for more configuration options. diff --git a/docs/docs/misc/scalability.mdx b/docs/docs/misc/scalability.mdx new file mode 100644 index 000000000..dcc001250 --- /dev/null +++ b/docs/docs/misc/scalability.mdx @@ -0,0 +1,7 @@ +--- +title: "Scalability" +--- + +One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. See the [sizing guide](/docs/deployment/sizing-guide) for detailed recommendations. + +Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). diff --git a/docs/docs/misc/service-ping.mdx b/docs/docs/misc/service-ping.mdx new file mode 100644 index 000000000..5e5ef20d5 --- /dev/null +++ b/docs/docs/misc/service-ping.mdx @@ -0,0 +1,62 @@ +--- +title: "Service Ping" +api: "GET https://license.sourcebot.dev/schema" +--- + + + No sensitive data (source code, AI inputs/outputs, credentials, user information, etc) is ever transmitted. + + +By default, all Sourcebot deployments will send a Service Ping, which is an HTTPS encrypted ping to `https://license.sourcebot.dev/ping` at port 443, every 24 hours. + +The data contained within the Service Ping is limited to: + +| Field | Type | Description | +| --- | --- | --- | +| `installId` | `string` | Random UUID generated for your deployment. | +| `hostname` | `string` | Hostname of your Sourcebot deployment. | +| `version` | `string` | Sourcebot version (e.g. `v5.3.0`). | +| `userCount` | `integer` | Total registered users. | +| `repoCount` | `integer` | Total indexed repositories. | +| `dauCount` | `integer` | Daily active users. | +| `wauCount` | `integer` | Weekly active users. | +| `mauCount` | `integer` | Monthly active users. | +| `deploymentType` | `string` | Deployment flavor (e.g. `docker`, `helm`). | +| `isTelemetryEnabled` | `boolean` | Whether anonymous product telemetry is enabled. | +| `activationCode` | `string` | Activation code, if your instance has one bound. | + + +You can fetch the schema for the Service Ping by submitting a GET request to `https://license.sourcebot.dev/schema` + + +```json wrap icon="wifi" Example Service Ping +{ + "method": "POST", + "url": "http://localhost:3003/ping", + "path": "/ping", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "accept-language": "*", + "connection": "keep-alive", + "content-length": "253", + "content-type": "application/json", + "host": "localhost:3003", + "sec-fetch-mode": "cors", + "user-agent": "node" + }, + "query": {} +} +body: { + "installId": "524a8f59-90aa-480d-a571-92dd8bzzzzze", + "version": "v4.17.2", + "userCount": 1, + "repoCount": 3, + "dauCount": 1, + "wauCount": 1, + "mauCount": 1, + "deploymentType": "other", + "isTelemetryEnabled": true, + "activationCode": "sb_act_3a925f51...." +} +``` \ No newline at end of file diff --git a/docs/docs/misc/telemetry.mdx b/docs/docs/misc/telemetry.mdx new file mode 100644 index 000000000..7fed997d8 --- /dev/null +++ b/docs/docs/misc/telemetry.mdx @@ -0,0 +1,22 @@ +--- +title: "Telemetry" +--- + +By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. + +The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. + +If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: + +```bash +docker run \ + -e SOURCEBOT_TELEMETRY_DISABLED=true \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: + +```sh +Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. +``` diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 3284d7b7d..8a770823d 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,221 +2,27 @@ title: "Overview" --- -[Sourcebot](https://github.com/sourcebot-dev/sourcebot) is a platform that helps humans and agents understand your codebase: - -- [Code search](/docs/features/search/overview): Search and navigate across all your repos and branches, no matter where they’re hosted -- [Ask Sourcebot](/docs/features/ask): Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations -- [MCP](/docs/features/mcp-server): Enrich agent context windows with code across your organization - - - Learn how to self-host Sourcebot in a few simple steps. - - - - - - **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more. - - **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine. - - **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc. - - **Scalable:** Scales to millions of lines of code. - - **Fair-source:** Core features are [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license). - - - -## Features ---- - - - Find an overview of all Sourcebot features below. For details, see the individual documentation pages. - - -### Ask Sourcebot - -[Ask Sourcebot](/docs/features/ask) gives you the ability to ask complex questions about your codebase, and have Sourcebot provide detailed answers with inline citations. - - -- **Bring your own model:** [Configure](/docs/configuration/language-model-providers) to any language model you'd like -- **Inline citations:** Every answer Sourcebot provides is grounded with inline citations directly into your codebase -- **Multi-repo:** Ask questions about any repository you have indexed on Sourcebot - - - - -### Code Search - -Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. - - -- **Regex support:** Use regular expressions to find code with precision. -- **Query language:** Scope searches to specific files, repos, languages, symbol definitions and more using a rich [query language](/docs/features/search/syntax-reference). -- **Branch search:** Specify a list of branches to search across ([docs](/docs/features/search/multi-branch-indexing)). -- **Fast & scalable:** Sourcebot uses [trigram indexing](https://en.wikipedia.org/wiki/Trigram_search), allowing it to scale to massive codebases. -- **Syntax highlighting:** Syntax highlighting support for over [100+ languages](https://github.com/sourcebot-dev/sourcebot/blob/57724689303f351c279d37f45b6406f1d5d5d5ab/packages/web/src/lib/codemirrorLanguage.ts#L125). -- **Multi-repository:** Search across all of your repositories in a single search. -- **Search suggestions:** Get search suggestions as you craft your query. -- **Filter panel:** Filter results by repository or by language. - - - - -### Code Navigation - -[Code navigation](/docs/features/code-navigation) helps you jump between symbol definitions and references quickly when browsing source code in Sourcebot. - - -- **Hover popover:** Hovering over a symbol reveals the symbol's definition signature in a inline preview. -- **Go to definition:** Navigate to a symbol's definition(s). -- **Find references:** Get all references to a symbol. -- **Cross-repository:** Sourcebot can resolve references and definitions across repositories. - - - - - -### Cross code-host support - -Connect your code from multiple code-host platforms and search across all of them from a single interface. - - -- **Auto re-syncing:** Sourcebot will periodically sync with code hosts to pull the latest changes. -- **Flexible configuration:** Sourcebot uses an expressive [JSON schema](/docs/connections/overview) config format to specify exactly what repositories to index (and what not to index). -- **Parallel indexing:** Repositories are indexed in parallel. - - - - - - - - - - - - - - - - - - - - - -### Authentication - -Sourcebot comes with built-in support for authentication via [email/password](/docs/configuration/auth/providers#email-%2F-password), [email codes](/docs/configuration/auth/providers#email-codes), and various [SSO providers](/docs/configuration/auth/providers#enterprise-authentication-providers). - - -- **Configurable auth providers:** Configure the auth providers that are available to your team. -- **SSO:** Support for various SSO providers. -- **_(coming soon)_ RBAC:** Role-based access control for managing user permissions. -- **_(coming soon)_ Code host permission syncing:** Sync permissions from GitHub, Gitlab, etc. to Sourcebot. -- **_(coming soon)_ Audit logs:** Audit logs for all actions performed on Sourcebot, such as user login, search, etc. - - - - -### Self-hosted - -Sourcebot is designed to be easily self-hosted, allowing you to deploy it onto your own infrastructure, keeping your code private and secure. - - -- **Easy deployment:** Sourcebot is shipped as a [single docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed to a k8s cluster, a VM, or any other platform that supports docker. -- **Secure:** Your code **never** leaves your infrastructure. -- **No-vendor lock-in:** Avoid dependency on a third-party SaaS provider; you can modify, extend, or migrate your deployment as needed. - - -## Get started ---- - - - - - - +Sourcebot is an [open source](https://github.com/sourcebot-dev/sourcebot) tool that helps humans and agents understand massive codebases. + + + + Search and navigate across all your repos and branches. + + + Ask questions across all your repos and branches. + + + Code context layer for all your agents. + -## Architecture ---- - -Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): - -![architecture diagram](/images/architecture_diagram.png) - -{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} - -Sourcebot consists of the following components: -- **Web Server** : main Next.js web application serving the Sourcebot UI. -- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. -- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. -- **Postgres** : transactional database for storing business-logic data. -- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. -- **`.sourcebot/` cache** : file-system cache where persistent data is written. - -You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [environment variables](/docs/configuration/environment-variables) doc for more configuration options. - -## Scalability ---- - -One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. See the [sizing guide](/docs/deployment/sizing-guide) for detailed recommendations. - -Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). - -## License key ---- - -Sourcebot's core features are available under an [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) without any limits. Some [additional features](/docs/license-key#feature-availability) such as SSO and code navigation require a [license key](/docs/license-key). +Sourcebot is self-hosted within your infrastructure, and connects to any [code-host platform](/docs/connections/indexing-your-code) or [LLM provider](/docs/configuration/language-model-providers). Sensitive data never leaves your infrastructure. - - - - -## Telemetry ---- - -By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. - -The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. - -If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: - -```bash -docker run \ - -e SOURCEBOT_TELEMETRY_DISABLED=true \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: - -```sh -Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. -``` \ No newline at end of file + + Deploy Sourcebot in your own infrastructure. + + + Try Sourcebot for free on our hosted version. + + \ No newline at end of file diff --git a/docs/docs/upgrade/v3-to-v4-guide.mdx b/docs/docs/upgrade/v3-to-v4-guide.mdx index 47620d0cd..304306520 100644 --- a/docs/docs/upgrade/v3-to-v4-guide.mdx +++ b/docs/docs/upgrade/v3-to-v4-guide.mdx @@ -8,7 +8,7 @@ This guide will walk you through upgrading your Sourcebot deployment from v3 to Please note that the following features are no longer supported in v4: - Multi-tenancy mode -- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/docs/license-key) +- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/docs/activating-a-subscription) ### If your deployment doesn't have authentication enabled @@ -22,7 +22,7 @@ Please note that the following features are no longer supported in v4: When you visit your new deployment you'll be presented with a sign-in page. Sourcebot now requires authentication, and all users must register and sign-in to the deployment. The first account that's registered will be made the owner. By default, you can register using basic credentials which will be stored encrypted within the postgres DB connected to Sourcebot. Check out - the [auth docs](/docs/configuration/auth/overview) to setup additional auth providers. + the [auth docs](/docs/configuration/auth/authentication) to setup additional auth providers. diff --git a/docs/docs/upgrade/v4-to-v5-guide.mdx b/docs/docs/upgrade/v4-to-v5-guide.mdx new file mode 100644 index 000000000..527b1672c --- /dev/null +++ b/docs/docs/upgrade/v4-to-v5-guide.mdx @@ -0,0 +1,32 @@ +--- +title: V4 to V5 Guide +sidebarTitle: V4 to V5 guide +--- + +This guide will walk you through upgrading your Sourcebot deployment from v4 to v5. + + +Starting in v5, the following features require a paid plan: +- [Ask Sourcebot](/docs/features/ask/ask-sourcebot) +- [MCP server](/docs/features/mcp-server) + +If your deployment uses these features and you upgrade to v5, you'll need to activate a paid plan to keep using them. All other features available on the free plan remain on the free plan. + +You can [start a 14-day free trial](/docs/free-trial) of the paid plan directly from your deployment. No credit card is required. + + +There are no breaking changes between v4 and v5. Upgrading is as simple as pulling the latest image and restarting your deployment. + +# Staying on v4 + +If you'd rather not upgrade to v5 or activate a paid plan, you can keep using v4 indefinitely by pinning your deployment to the final v4 release: + +```bash +ghcr.io/sourcebot-dev/sourcebot:v4.17.3 +``` + +v4 will continue to work as before, with Ask Sourcebot and MCP available for free. + +## Troubleshooting + +Having troubles migrating from v4 to v5? Reach out to us on [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) and we'll try our best to help. diff --git a/docs/snippets/license-key-required.mdx b/docs/snippets/license-key-required.mdx index 660e48699..be37fc25d 100644 --- a/docs/snippets/license-key-required.mdx +++ b/docs/snippets/license-key-required.mdx @@ -1,4 +1,4 @@ -{feature} is only available in a paid plan. Please add your [license key](/docs/license-key) to activate it. - \ No newline at end of file +{feature} {verb ?? "is"} only available in a paid plan. Please activate a [license key](/docs/activating-a-subscription) to use this feature. + diff --git a/packages/setupWizard/README.md b/packages/setupWizard/README.md index ddd2be912..4224b2fc2 100644 --- a/packages/setupWizard/README.md +++ b/packages/setupWizard/README.md @@ -13,7 +13,7 @@ npx setup-sourcebot The wizard walks you through: - **Code hosts** — GitHub, GitLab, Bitbucket (Cloud or Data Center), Azure DevOps (Cloud or Server), Gitea, Gerrit, a local folder of cloned repos, or any other git URL. -- **AI providers** (optional) — Anthropic, OpenAI, Google Gemini, Google Vertex, DeepSeek, Mistral, xAI, OpenRouter, OpenAI-compatible endpoints, Amazon Bedrock, or Azure OpenAI. Powers [Ask](https://docs.sourcebot.dev/docs/features/ask/overview). +- **AI providers** (optional) — Anthropic, OpenAI, Google Gemini, Google Vertex, DeepSeek, Mistral, xAI, OpenRouter, OpenAI-compatible endpoints, Amazon Bedrock, or Azure OpenAI. Powers [Ask](https://docs.sourcebot.dev/docs/features/ask/ask-sourcebot). ## Requirements diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index 852ef73ba..8d66a6c6c 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -25,8 +25,7 @@ import { note, } from './utils.js'; -// @nocheckin: change this to main -const DOCKER_COMPOSE_BRANCH = 'v5'; +const DOCKER_COMPOSE_BRANCH = 'main'; const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; const SOURCEBOT_URL = 'http://localhost:3000'; diff --git a/packages/setupWizard/src/models.ts b/packages/setupWizard/src/models.ts index 14ecf5f66..50faea65f 100644 --- a/packages/setupWizard/src/models.ts +++ b/packages/setupWizard/src/models.ts @@ -303,7 +303,7 @@ export async function collectModels(): Promise<{ models: LanguageModel[]; env: E [ 'AI features include Ask, which lets you ask questions about your codebase', 'in natural language and get answers grounded in your indexed code.', - ' https://docs.sourcebot.dev/docs/features/ask/overview', + ' https://docs.sourcebot.dev/docs/features/ask/ask-sourcebot', '', 'You\'ll need an API key from at least one supported provider', '(Anthropic, OpenAI, Google, etc.) to enable these features.', diff --git a/packages/web/src/app/(app)/chat/components/tutorialDialog.tsx b/packages/web/src/app/(app)/chat/components/tutorialDialog.tsx index f7b3e943f..d8663e95d 100644 --- a/packages/web/src/app/(app)/chat/components/tutorialDialog.tsx +++ b/packages/web/src/app/(app)/chat/components/tutorialDialog.tsx @@ -220,7 +220,7 @@ const tutorialSteps = [ You're all set!

- You can now ask Sourcebot any question about your codebase. Checkout the docs for more information. + You can now ask Sourcebot any question about your codebase. Checkout the docs for more information.

Hit a bug? Open up an issue. diff --git a/packages/web/src/app/(app)/components/banners/servicePingFailedBanner.tsx b/packages/web/src/app/(app)/components/banners/servicePingFailedBanner.tsx index f1848ac93..7771c056b 100644 --- a/packages/web/src/app/(app)/components/banners/servicePingFailedBanner.tsx +++ b/packages/web/src/app/(app)/components/banners/servicePingFailedBanner.tsx @@ -13,8 +13,7 @@ interface ServicePingFailedBannerProps extends BannerProps { lastSyncAt: string | null; } -// @nocheckin: link to the service ping docs here when ready. -const SERVICE_PING_DOCS_LINK = "https://docs.sourcebot.dev/docs"; +const SERVICE_PING_DOCS_LINK = "https://docs.sourcebot.dev/docs/misc/service-ping"; export function ServicePingFailedBanner({ id, diff --git a/packages/web/src/app/(app)/components/searchModeSelector.tsx b/packages/web/src/app/(app)/components/searchModeSelector.tsx index 4b2ef3e95..610f6b5dc 100644 --- a/packages/web/src/app/(app)/components/searchModeSelector.tsx +++ b/packages/web/src/app/(app)/components/searchModeSelector.tsx @@ -13,9 +13,9 @@ import { useHotkeys } from "react-hotkeys-hook"; export type SearchMode = "precise" | "agentic"; -const PRECISE_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/overview"; +const PRECISE_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/code-search"; // @tood: point this to the actual docs page -const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/overview"; +const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/ask-sourcebot"; export interface SearchModeSelectorProps { searchMode: SearchMode; diff --git a/packages/web/src/app/(app)/settings/connections/page.tsx b/packages/web/src/app/(app)/settings/connections/page.tsx index 2b046cabe..fdce7b08f 100644 --- a/packages/web/src/app/(app)/settings/connections/page.tsx +++ b/packages/web/src/app/(app)/settings/connections/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { ConnectionsTable } from "./components/connectionsTable"; import { ConnectionSyncJobStatus } from "@prisma/client"; -const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview"; +const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/indexing-your-code"; export default async function ConnectionsPage() { const _connections = await getConnectionsWithLatestJob(); diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index f309e7e04..0e42fb478 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -51,7 +51,7 @@ export const AuthMethodSelector = ({ return (

No authentication methods available. Please contact your administrator to configure authentication.

- Learn more + Learn more
) } diff --git a/packages/web/src/app/onboard/components/trialStep.tsx b/packages/web/src/app/onboard/components/trialStep.tsx index 95e05a6a4..636c7f40a 100644 --- a/packages/web/src/app/onboard/components/trialStep.tsx +++ b/packages/web/src/app/onboard/components/trialStep.tsx @@ -174,7 +174,19 @@ export function TrialStep({ stepIndex }: TrialStepProps) { return; } - window.location.assign(checkoutResult.url); + try { + const checkoutUrl = new URL(checkoutResult.url); + if (checkoutUrl.protocol !== "https:" && checkoutUrl.protocol !== "http:") { + throw new Error("Unsupported checkout URL protocol."); + } + window.location.assign(checkoutUrl.toString()); + } catch { + toast({ + description: "Failed to start checkout: invalid checkout URL.", + variant: "destructive", + }); + setIsPrimaryLoading(false); + } }, [billingInterval, stepIndex, toast, overrideEmail]); if (isPending) { diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index 1149fd56c..8c156ac1e 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -95,7 +95,7 @@ export default async function Onboarding(props: OnboardingProps) { <> Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "} | ServiceError> => { + const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/schema`, { + method: 'GET', + }); + + return parseResponseBody(response, z.record(z.string(), z.unknown())); + }, + checkout: async (body: CheckoutRequest): Promise => { const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/checkout`, { method: 'POST', diff --git a/packages/web/src/ee/features/lighthouse/planComparisonTable.tsx b/packages/web/src/ee/features/lighthouse/planComparisonTable.tsx index 1dc586ed3..7f5e6634c 100644 --- a/packages/web/src/ee/features/lighthouse/planComparisonTable.tsx +++ b/packages/web/src/ee/features/lighthouse/planComparisonTable.tsx @@ -109,7 +109,7 @@ export function PlanComparisonTable({ - + @@ -119,7 +119,7 @@ export function PlanComparisonTable({ - + diff --git a/packages/web/src/ee/features/lighthouse/upsellDialog.tsx b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx index fb4410feb..5984d64f9 100644 --- a/packages/web/src/ee/features/lighthouse/upsellDialog.tsx +++ b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx @@ -168,7 +168,20 @@ function UpsellPanelContent({ offers, source, returnPath, variant, licenseState }); setIsCheckoutSessionCreating(false); } else { - window.location.assign(response.url); + try { + const checkoutUrl = new URL(response.url); + const allowedHosts = new Set(["checkout.stripe.com", "billing.stripe.com"]); + if (checkoutUrl.protocol !== "https:" || !allowedHosts.has(checkoutUrl.hostname)) { + throw new Error("Untrusted checkout URL."); + } + window.location.assign(checkoutUrl.toString()); + } catch { + toast({ + description: "Failed to start checkout. Please try again.", + variant: "destructive", + }); + setIsCheckoutSessionCreating(false); + } } }) .catch(() => { diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index d4b2d599c..66764eb5b 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -8,5 +8,4 @@ export const SINGLE_TENANT_ORG_NAME = 'default'; export { SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared/client"; -// @nocheckin: This should instead be a docs page that explains our offerings export const OFFERINGS_DOCS_LINK = "https://sourcebot.dev/pricing" \ No newline at end of file diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index 07c42b71f..0a1a1ca2d 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -39,10 +39,9 @@ const gitTag = { name: 'Git', description: 'Git history, diff, and file content const systemTag = { name: 'System', description: 'System health and version endpoints.' }; const eeTag = { name: 'Enterprise (EE)', description: 'Enterprise endpoints for user management and audit logging.' }; -// @nocheckin: The "More information" link will need to point to the correct docs page const EE_LICENSE_KEY_NOTE = dedent` -This API is only available with an active Sourcebot license. [More information](/docs/license-key). +This API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription). `; From 6739694e42d978abc35a420fbe29fb8504775aad Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 29 May 2026 00:55:03 -0700 Subject: [PATCH 35/40] [v5] feat(web): Add usage information for yearly subs (#1245) * schema * feat --- .../migration.sql | 10 + packages/db/prisma/schema.prisma | 14 + .../components/banners/bannerResolver.test.ts | 10 + .../licenseInactiveBanner.tsx | 0 .../onlineLicenseCard.tsx | 13 + .../planActionsMenu.tsx | 0 .../removeActivationCodeDialog.tsx | 0 .../trialMissingPaymentMethodBanner.tsx | 0 .../upcomingYearlyRenewalBanner.tsx | 89 +++++++ .../src/app/(app)/settings/license/page.tsx | 15 +- .../src/app/(app)/settings/license/types.ts | 41 +++ .../license/yearlyTermSeatsUsageCard.tsx | 247 ++++++++++++++++++ .../src/ee/features/lighthouse/servicePing.ts | 11 + .../web/src/ee/features/lighthouse/types.ts | 15 ++ 14 files changed, 464 insertions(+), 1 deletion(-) rename packages/web/src/app/(app)/settings/license/{ => onlineLicenseCard}/licenseInactiveBanner.tsx (100%) rename packages/web/src/app/(app)/settings/license/{ => onlineLicenseCard}/onlineLicenseCard.tsx (92%) rename packages/web/src/app/(app)/settings/license/{ => onlineLicenseCard}/planActionsMenu.tsx (100%) rename packages/web/src/app/(app)/settings/license/{ => onlineLicenseCard}/removeActivationCodeDialog.tsx (100%) rename packages/web/src/app/(app)/settings/license/{ => onlineLicenseCard}/trialMissingPaymentMethodBanner.tsx (100%) create mode 100644 packages/web/src/app/(app)/settings/license/onlineLicenseCard/upcomingYearlyRenewalBanner.tsx create mode 100644 packages/web/src/app/(app)/settings/license/types.ts create mode 100644 packages/web/src/app/(app)/settings/license/yearlyTermSeatsUsageCard.tsx diff --git a/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql b/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql index 44074dd1d..ca742bee3 100644 --- a/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql +++ b/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql @@ -16,6 +16,16 @@ CREATE TABLE "License" ( "cancelAt" TIMESTAMP(3), "trialEnd" TIMESTAMP(3), "hasPaymentMethod" BOOLEAN, + "yearlyCommittedSeats" INTEGER, + "yearlyCurrentQuarterEndsAt" TIMESTAMP(3), + "yearlyCurrentQuarterNumber" INTEGER, + "yearlyCurrentQuarterStartedAt" TIMESTAMP(3), + "yearlyOverageSeats" INTEGER, + "yearlyBillableOverageSeats" INTEGER, + "yearlyPeakSeats" INTEGER, + "yearlyTermEndsAt" TIMESTAMP(3), + "yearlyTermStartedAt" TIMESTAMP(3), + "yearlyTotalQuartersInTerm" INTEGER, "lastSyncAt" TIMESTAMP(3), "lastSyncErrorCode" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 112abf301..e0371e56c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -317,6 +317,20 @@ model License { cancelAt DateTime? trialEnd DateTime? hasPaymentMethod Boolean? + + // Yearly-only fields, mirroring `yearlyTermStatus` on the lighthouse ping + // response. All null for monthly subs and for unactivated/canceled licenses. + yearlyTermStartedAt DateTime? + yearlyTermEndsAt DateTime? + yearlyTotalQuartersInTerm Int? + yearlyCurrentQuarterNumber Int? + yearlyCurrentQuarterStartedAt DateTime? + yearlyCurrentQuarterEndsAt DateTime? + yearlyCommittedSeats Int? + yearlyOverageSeats Int? + yearlyBillableOverageSeats Int? + yearlyPeakSeats Int? + lastSyncAt DateTime? lastSyncErrorCode String? createdAt DateTime @default(now()) diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts index 5414fca39..40bd558ed 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts @@ -48,6 +48,16 @@ const makeLicense = (overrides: Partial = {}): License => ({ cancelAt: null, trialEnd: null, hasPaymentMethod: null, + yearlyTermStartedAt: null, + yearlyTermEndsAt: null, + yearlyTotalQuartersInTerm: null, + yearlyCurrentQuarterNumber: null, + yearlyCurrentQuarterStartedAt: null, + yearlyCurrentQuarterEndsAt: null, + yearlyCommittedSeats: null, + yearlyOverageSeats: null, + yearlyBillableOverageSeats: null, + yearlyPeakSeats: null, lastSyncAt: NOW, lastSyncErrorCode: null, createdAt: NOW, diff --git a/packages/web/src/app/(app)/settings/license/licenseInactiveBanner.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/licenseInactiveBanner.tsx similarity index 100% rename from packages/web/src/app/(app)/settings/license/licenseInactiveBanner.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard/licenseInactiveBanner.tsx diff --git a/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/onlineLicenseCard.tsx similarity index 92% rename from packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard/onlineLicenseCard.tsx index 2cfa5a086..446dc062b 100644 --- a/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx +++ b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/onlineLicenseCard.tsx @@ -11,6 +11,7 @@ import { cn, formatCurrency } from "@/lib/utils"; import { LicenseInactiveBanner } from "./licenseInactiveBanner"; import { PlanActionsMenu } from "./planActionsMenu"; import { TrialMissingPaymentMethodBanner } from "./trialMissingPaymentMethodBanner"; +import { UpcomingYearlyRenewalBanner } from "./upcomingYearlyRenewalBanner"; interface OnlineLicenseCardProps { license: License; @@ -123,6 +124,18 @@ export function OnlineLicenseCard({ license }: OnlineLicenseCardProps) { {isLicenseActive && license.status === 'trialing' && license.hasPaymentMethod === false && ( )} + {isLicenseActive + && license.interval === 'year' + && license.yearlyCurrentQuarterNumber !== null + && license.yearlyTotalQuartersInTerm !== null + && license.yearlyCurrentQuarterNumber === license.yearlyTotalQuartersInTerm + && license.nextRenewalAt !== null + && license.seats !== null && ( + + )}
); } diff --git a/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/planActionsMenu.tsx similarity index 100% rename from packages/web/src/app/(app)/settings/license/planActionsMenu.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard/planActionsMenu.tsx diff --git a/packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/removeActivationCodeDialog.tsx similarity index 100% rename from packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard/removeActivationCodeDialog.tsx diff --git a/packages/web/src/app/(app)/settings/license/trialMissingPaymentMethodBanner.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/trialMissingPaymentMethodBanner.tsx similarity index 100% rename from packages/web/src/app/(app)/settings/license/trialMissingPaymentMethodBanner.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard/trialMissingPaymentMethodBanner.tsx diff --git a/packages/web/src/app/(app)/settings/license/onlineLicenseCard/upcomingYearlyRenewalBanner.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/upcomingYearlyRenewalBanner.tsx new file mode 100644 index 000000000..e2072bb08 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/onlineLicenseCard/upcomingYearlyRenewalBanner.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Info } from "lucide-react"; +import { useToast } from "@/components/hooks/use-toast"; +import { createPortalSession } from "@/ee/features/lighthouse/actions"; +import { isServiceError } from "@/lib/utils"; + +// @note take care to keep this in sync with +// the value in constants.ts in lighthouse. +const YEARLY_CANCELLATION_NOTICE_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +interface UpcomingRenewalBannerProps { + renewalAt: Date; + seats: number; +} + +export function UpcomingYearlyRenewalBanner({ renewalAt, seats }: UpcomingRenewalBannerProps) { + const router = useRouter(); + const { toast } = useToast(); + const [isOpeningPortal, setIsOpeningPortal] = useState(false); + const [nowMs] = useState(() => Date.now()); + + const handleCancelClick = useCallback(() => { + setIsOpeningPortal(true); + createPortalSession().then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to open subscription portal: ${response.message}`, + variant: "destructive", + }); + setIsOpeningPortal(false); + return; + } + router.push(response.url); + }); + }, [router, toast]); + + const daysUntilRenewal = (renewalAt.getTime() - nowMs) / DAY_MS; + const noticeDeadlineHasPassed = daysUntilRenewal < YEARLY_CANCELLATION_NOTICE_DAYS; + const noticeDeadline = new Date(renewalAt.getTime() - YEARLY_CANCELLATION_NOTICE_DAYS * DAY_MS); + + return ( + + ); +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 2c63c7a27..97b24f1ab 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -5,14 +5,16 @@ import { Button } from "@/components/ui/button"; import { ExternalLink } from "lucide-react"; import { redirect } from "next/navigation"; import { ActivationCodeCard } from "./activationCodeCard"; -import { OnlineLicenseCard } from "./onlineLicenseCard"; +import { OnlineLicenseCard } from "./onlineLicenseCard/onlineLicenseCard"; import { OfflineLicenseCard } from "./offlineLicenseCard"; import { RecentInvoicesCard } from "./recentInvoicesCard"; +import { YearlyTermSeatsUsageCard } from "./yearlyTermSeatsUsageCard"; import { SettingsCard } from "../components/settingsCard"; import { UpsellPanel } from "@/ee/features/lighthouse/upsellDialog"; import { getAllInvoices } from "@/ee/features/lighthouse/actions"; import { syncWithLighthouse } from "@/ee/features/lighthouse/servicePing"; import { isServiceError } from "@/lib/utils"; +import { getYearlyTermStatus } from "./types"; type LicensePageProps = { searchParams?: Promise>; @@ -36,6 +38,7 @@ export default authenticatedPage(async ({ prisma, org }, props redirect(suffix ? `/settings/license?${suffix}` : '/settings/license'); } + const offlineLicense = getOfflineLicenseMetadata(); const isOfflineLicenseExpired = offlineLicense ? new Date(offlineLicense.expiryDate).getTime() < Date.now() @@ -45,6 +48,9 @@ export default authenticatedPage(async ({ prisma, org }, props ? null : await prisma.license.findUnique({ where: { orgId: org.id } }); + const yearlyTermStatus = getYearlyTermStatus(license); + const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } }); + const invoicesResult = license ? await getAllInvoices() : null; const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : []; @@ -87,6 +93,13 @@ export default authenticatedPage(async ({ prisma, org }, props )} {license && } + {license + && yearlyTermStatus && ( + + )} {!offlineLicense && !license && } {license && }
diff --git a/packages/web/src/app/(app)/settings/license/types.ts b/packages/web/src/app/(app)/settings/license/types.ts new file mode 100644 index 000000000..f4fbbc9c3 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/types.ts @@ -0,0 +1,41 @@ +import { YearlyTermStatus as RawYearlyTermStatus } from "@/ee/features/lighthouse/types"; +import { License } from "@sourcebot/db"; + +export type YearlyTermStatus = Omit & { + currentQuarterStartedAt: Date, + currentQuarterEndsAt: Date, + termStartedAt: Date, + termEndsAt: Date, +} + +export function getYearlyTermStatus( + license: License | null, +): YearlyTermStatus | undefined { + if ( + license === null + || license.yearlyCommittedSeats === null + || license.yearlyPeakSeats === null + || license.yearlyOverageSeats === null + || license.yearlyBillableOverageSeats === null + || license.yearlyCurrentQuarterNumber === null + || license.yearlyTotalQuartersInTerm === null + || license.yearlyCurrentQuarterStartedAt === null + || license.yearlyCurrentQuarterEndsAt === null + || license.yearlyTermStartedAt === null + || license.yearlyTermEndsAt === null + ) { + return undefined; + } + return { + committedSeats: license.yearlyCommittedSeats, + peakSeats: license.yearlyPeakSeats, + overageSeats: license.yearlyOverageSeats, + billableOverageSeats: license.yearlyBillableOverageSeats, + currentQuarterNumber: license.yearlyCurrentQuarterNumber, + totalQuartersInTerm: license.yearlyTotalQuartersInTerm, + currentQuarterStartedAt: license.yearlyCurrentQuarterStartedAt, + currentQuarterEndsAt: license.yearlyCurrentQuarterEndsAt, + termStartedAt: license.yearlyTermStartedAt, + termEndsAt: license.yearlyTermEndsAt, + }; +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/license/yearlyTermSeatsUsageCard.tsx b/packages/web/src/app/(app)/settings/license/yearlyTermSeatsUsageCard.tsx new file mode 100644 index 000000000..bc92f75d9 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/yearlyTermSeatsUsageCard.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { Info } from "lucide-react"; +import { ReactNode, useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useToast } from "@/components/hooks/use-toast"; +import { refreshLicense } from "@/ee/features/lighthouse/actions"; +import { cn, isServiceError } from "@/lib/utils"; +import { SettingsCard } from "../components/settingsCard"; +import { YearlyTermStatus } from "./types"; + +const DOCS_URL = "https://docs.sourcebot.dev/docs/billing"; + + +interface YearlyTermSeatsUsageCardProps { + currentUsers: number; + status: YearlyTermStatus; +} + +export function YearlyTermSeatsUsageCard({ + currentUsers, + status: { + committedSeats, + peakSeats, + overageSeats, + billableOverageSeats, + currentQuarterNumber, + totalQuartersInTerm, + currentQuarterEndsAt, + termEndsAt, + } +}: YearlyTermSeatsUsageCardProps) { + // currentQuarterNumber > totalQuartersInTerm means the final reconciliation + // has fired but term rollover hasn't (or won't, for canceled subs). See + // lighthouse `lambda/yearly.ts` for the design note. + const isTermComplete = currentQuarterNumber > totalQuartersInTerm; + + const router = useRouter(); + const { toast } = useToast(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + refreshLicense() + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to refresh license: ${response.message}`, + variant: "destructive", + }); + } else { + toast({ description: "License refreshed." }); + router.refresh(); + } + }) + .finally(() => { + setIsRefreshing(false); + }); + }, [router, toast]); + + const hasOverage = billableOverageSeats > 0; + const committedUsedPercent = hasOverage + ? 90 + : committedSeats > 0 + ? Math.min(100, (currentUsers / committedSeats) * 100) + : 0; + const overagePercent = hasOverage ? 10 : 0; + + return ( +
+
+

Usage

+

+ Track your organization's seat usage and any pending overage charges for the current subscription term, ending on {formatDate(termEndsAt)}.{" "} + + Learn more + +

+
+
+
+ +
+
+

+ {currentUsers} / {committedSeats} +

+ {billableOverageSeats > 0 ? ( + + + + Bill pending + + + + You'll be invoiced for {billableOverageSeats} additional {billableOverageSeats === 1 ? 'seat' : 'seats'} at the end of the current quarter on {formatDate(currentQuarterEndsAt)}. + + + ) : ( + overageSeats > 0 && + billableOverageSeats === 0 && + (currentUsers - committedSeats) > 0 + ) ? ( + + + + + + {overageSeats === 1 + ? `The additional seat in use will not be billed in the current subscription term. Instead, it will be billed upon renewal on ${formatDate(termEndsAt)}.` + : `The ${overageSeats} additional seats in use will not be billed in the current subscription term. Instead, they will be billed upon renewal on ${formatDate(termEndsAt)}.`} + + + ) : ( + + + + + + {(committedSeats - currentUsers) > 0 + ? `${committedSeats - currentUsers} of ${committedSeats} seats remaining. ` + : `All ${committedSeats} seats are in use. `} + Additional users will be billed as overage at the end of each quarter. + + + )} +
+
+
+ {overagePercent > 0 && ( +
+ )} +
+ +

+ Seats in use:{" "} + the number of users in your organization right now. +

+

+ Seats in subscription:{" "} + the seats you've paid for so far in your current subscription term. +

+
+ } + labelClassName="font-semibold" + /> + {isTermComplete ? ( +
+

+ Term complete, awaiting renewal. +

+ +
+ ) : ( +

+ Quarter {currentQuarterNumber} of {totalQuartersInTerm} · term ends {formatDate(termEndsAt)} +

+ )} +
+ +
+ + +
+
+

{peakSeats}

+ +
+
+

+ {billableOverageSeats} +

+ +
+
+
+
+
+ ); +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +interface LabelWithInfoProps { + label: string; + tooltip: ReactNode; + labelClassName?: string; +} + +function LabelWithInfo({ label, tooltip, labelClassName }: LabelWithInfoProps) { + return ( +
+

{label}

+ + + + + + {tooltip} + + +
+ ); +} diff --git a/packages/web/src/ee/features/lighthouse/servicePing.ts b/packages/web/src/ee/features/lighthouse/servicePing.ts index 689932c08..05948a2eb 100644 --- a/packages/web/src/ee/features/lighthouse/servicePing.ts +++ b/packages/web/src/ee/features/lighthouse/servicePing.ts @@ -111,6 +111,7 @@ export const syncWithLighthouse = async (orgId: number) => { cancelAt, trialEnd, hasPaymentMethod, + yearlyTermStatus, } = response.license; await __unsafePrisma.license.update({ @@ -131,6 +132,16 @@ export const syncWithLighthouse = async (orgId: number) => { cancelAt: cancelAt ? new Date(cancelAt) : null, trialEnd: trialEnd ? new Date(trialEnd) : null, hasPaymentMethod, + yearlyTermStartedAt: yearlyTermStatus ? new Date(yearlyTermStatus.termStartedAt) : null, + yearlyTermEndsAt: yearlyTermStatus ? new Date(yearlyTermStatus.termEndsAt) : null, + yearlyTotalQuartersInTerm: yearlyTermStatus?.totalQuartersInTerm ?? null, + yearlyCurrentQuarterNumber: yearlyTermStatus?.currentQuarterNumber ?? null, + yearlyCurrentQuarterStartedAt: yearlyTermStatus ? new Date(yearlyTermStatus.currentQuarterStartedAt) : null, + yearlyCurrentQuarterEndsAt: yearlyTermStatus ? new Date(yearlyTermStatus.currentQuarterEndsAt) : null, + yearlyCommittedSeats: yearlyTermStatus?.committedSeats ?? null, + yearlyOverageSeats: yearlyTermStatus?.overageSeats ?? null, + yearlyBillableOverageSeats: yearlyTermStatus?.billableOverageSeats ?? null, + yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null, lastSyncAt: new Date(), lastSyncErrorCode: null, }, diff --git a/packages/web/src/ee/features/lighthouse/types.ts b/packages/web/src/ee/features/lighthouse/types.ts index 551059470..c85f32ad4 100644 --- a/packages/web/src/ee/features/lighthouse/types.ts +++ b/packages/web/src/ee/features/lighthouse/types.ts @@ -38,6 +38,20 @@ export const claimActivationCodeResponseSchema = z.object({ }); export type ClaimActivationCodeResponse = z.infer; +export const yearlyTermStatusSchema = z.object({ + termStartedAt: z.string().datetime(), + termEndsAt: z.string().datetime(), + totalQuartersInTerm: z.number().int(), + currentQuarterNumber: z.number().int(), + currentQuarterStartedAt: z.string().datetime(), + currentQuarterEndsAt: z.string().datetime(), + committedSeats: z.number().int(), + overageSeats: z.number().int(), + billableOverageSeats: z.number().int(), + peakSeats: z.number().int(), +}); +export type YearlyTermStatus = z.infer; + export const servicePingResponseSchema = z.object({ license: z.object({ entitlements: z.string().array(), @@ -53,6 +67,7 @@ export const servicePingResponseSchema = z.object({ cancelAt: z.string().datetime().nullable(), trialEnd: z.string().datetime().nullable(), hasPaymentMethod: z.boolean(), + yearlyTermStatus: yearlyTermStatusSchema.optional(), }).optional(), }); export type ServicePingResponse = z.infer; From 3d00a39432639182f40f5a063cd172aa8442d406 Mon Sep 17 00:00:00 2001 From: msukkari Date: Fri, 29 May 2026 09:26:41 -0700 Subject: [PATCH 36/40] fix build errors after merge --- packages/shared/src/entitlements.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts index 906ef516e..35fe4a65d 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -65,6 +65,16 @@ const makeLicense = (overrides: Partial = {}): License => ({ cancelAt: null, trialEnd: null, hasPaymentMethod: null, + yearlyTermStartedAt: null, + yearlyTermEndsAt: null, + yearlyTotalQuartersInTerm: null, + yearlyCurrentQuarterNumber: null, + yearlyCurrentQuarterStartedAt: null, + yearlyCurrentQuarterEndsAt: null, + yearlyCommittedSeats: null, + yearlyOverageSeats: null, + yearlyBillableOverageSeats: null, + yearlyPeakSeats: null, lastSyncAt: new Date(), lastSyncErrorCode: null, createdAt: new Date(), From 2c34b08bb8a3305a3f1d1918b0268a8712cf0967 Mon Sep 17 00:00:00 2001 From: msukkari Date: Fri, 29 May 2026 09:26:58 -0700 Subject: [PATCH 37/40] fix bug where Ask doesn't load if we dont have a license key --- .../(server)/ee/askmcp/servers/route.test.ts | 18 ++++++++++++++++++ .../api/(server)/ee/askmcp/servers/route.ts | 6 +----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts index 0a272ff8d..42417d501 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.test.ts @@ -72,6 +72,24 @@ beforeEach(() => { }); describe('GET /api/ee/askmcp/servers', () => { + test('returns an empty array when the oauth entitlement is not granted', async () => { + mocks.hasEntitlement.mockResolvedValue(false); + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-1' }, + prisma, + }; + + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual([]); + expect(prisma.mcpServer.findMany).not.toHaveBeenCalled(); + expect(prisma.userMcpServer.findMany).not.toHaveBeenCalled(); + }); + test('lists org servers and merges only the caller token status', async () => { const prisma = createPrismaMock(); mocks.authContext = { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts index fb6053209..8ccb3527d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/servers/route.ts @@ -3,7 +3,6 @@ import { serviceErrorResponse } from '@/lib/serviceError'; import { isServiceError } from '@/lib/utils'; import { withAuth } from '@/middleware/withAuth'; import { hasEntitlement } from '@/lib/entitlements'; -import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import { getMcpFaviconUrl } from '@/ee/features/chat/mcp/utils'; import { getStoredMcpConnectionStatus } from '@/ee/features/chat/mcp/connectionStatus'; import type { NextRequest } from 'next/server'; @@ -22,10 +21,7 @@ export type GetMcpServersResponse = McpServerWithStatus[]; export const GET = apiHandler(async (_request: NextRequest) => { if (!(await hasEntitlement('oauth'))) { - return Response.json( - { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, - { status: 403 } - ); + return Response.json([] satisfies GetMcpServersResponse); } const result = await withAuth(async ({ org, user, prisma }) => { From a7088e8657942546f604cbcdeba1b12a337b01af Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Fri, 29 May 2026 11:30:51 -0700 Subject: [PATCH 38/40] refactor(web): clean up MCP OAuth provider --- .../src/features/mcp/prismaOAuthClientProvider.test.ts | 2 +- .../web/src/features/mcp/prismaOAuthClientProvider.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts index cf40926f4..998181c7b 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.test.ts @@ -171,7 +171,7 @@ describe('PrismaOAuthClientProvider PKCE verifier storage', () => { await expect(provider.codeVerifier()).resolves.toBe('plaintext-verifier'); expect(mocks.logger.warn).toHaveBeenCalledWith( - 'MCP OAuth code verifier was read without decryption; it may be plaintext from an older version.', + 'MCP OAuth code verifier was read without decryption.', { serverId: 'server-1', orgId: 1, diff --git a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts index ca1b46508..263a0c66b 100644 --- a/packages/web/src/features/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/features/mcp/prismaOAuthClientProvider.ts @@ -202,7 +202,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } if (decrypted === userServer.codeVerifier) { - logger.warn('MCP OAuth code verifier was read without decryption; it may be plaintext from an older version.', { + logger.warn('MCP OAuth code verifier was read without decryption.', { serverId: this.serverId, orgId: this.orgId, userId: this.userId, @@ -249,12 +249,8 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { } async invalidateCredentials( - scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', + scope: 'all' | 'client' | 'tokens' | 'verifier', ): Promise { - if (scope === 'discovery') { - return; - } - if (scope === 'all' || scope === 'client') { const didClearDynamicClient = await clearMcpServerClientCredentialsForObservedClient({ prisma: this.clientInvalidationPrisma, From 7deb0502a18b55bb13135b148fc80d4d0d2e91f1 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Fri, 29 May 2026 14:50:30 -0700 Subject: [PATCH 39/40] Prisma Migrations --- .../migration.sql | 27 ------------------- .../migration.sql | 1 - .../migration.sql | 5 ---- .../migration.sql | 13 --------- .../migration.sql | 27 ++++++++++++++++++- 5 files changed, 26 insertions(+), 47 deletions(-) delete mode 100644 packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql delete mode 100644 packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql delete mode 100644 packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql delete mode 100644 packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql rename packages/db/prisma/migrations/{20260324182442_support_mcp_clients => 20260529214711_add_mcp_connectors_tables}/migration.sql (59%) diff --git a/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql b/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql deleted file mode 100644 index 64844aedb..000000000 --- a/packages/db/prisma/migrations/20260524000000_org_approved_mcp_servers/migration.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Add org-approved display/tool identity to shared MCP servers. -ALTER TABLE "McpServer" ADD COLUMN "name" TEXT; -ALTER TABLE "McpServer" ADD COLUMN "sanitizedName" TEXT; - --- Backfill existing rows before enforcing non-null display identity. -UPDATE "McpServer" -SET "name" = COALESCE( - ( - SELECT "UserMcpServer"."name" - FROM "UserMcpServer" - WHERE "UserMcpServer"."serverId" = "McpServer"."id" - ORDER BY "UserMcpServer"."createdAt" ASC - LIMIT 1 - ), - "McpServer"."serverUrl" -); - -UPDATE "McpServer" -SET "sanitizedName" = regexp_replace(lower("name"), '[^a-z0-9]', '_', 'g'); - -ALTER TABLE "McpServer" ALTER COLUMN "name" SET NOT NULL; -ALTER TABLE "McpServer" ALTER COLUMN "sanitizedName" SET NOT NULL; - --- Remove per-user display identity now that MCP servers are org-approved. -ALTER TABLE "UserMcpServer" DROP COLUMN "name"; - -CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName"); diff --git a/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql b/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql deleted file mode 100644 index d171bca2c..000000000 --- a/packages/db/prisma/migrations/20260525000000_add_user_mcp_server_server_id_index/migration.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId"); diff --git a/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql b/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql deleted file mode 100644 index 1f03e8968..000000000 --- a/packages/db/prisma/migrations/20260526000000_add_mcp_server_client_info_source/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Track whether McpServer.clientInfo came from dynamic client registration or admin-provided static credentials. -CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC'); - -ALTER TABLE "McpServer" -ADD COLUMN "clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC'; diff --git a/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql b/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql deleted file mode 100644 index 87b2a2200..000000000 --- a/packages/db/prisma/migrations/20260527181001_add_mcp_tool_call_counts/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- CreateTable -CREATE TABLE "McpServerToolCallCount" ( - "mcpServerId" TEXT NOT NULL, - "toolName" TEXT NOT NULL, - "count" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName") -); - --- AddForeignKey -ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql b/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql similarity index 59% rename from packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql rename to packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql index 30e6d30f9..fb82482c0 100644 --- a/packages/db/prisma/migrations/20260324182442_support_mcp_clients/migration.sql +++ b/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql @@ -1,8 +1,14 @@ +-- CreateEnum +CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC'); + -- CreateTable CREATE TABLE "McpServer" ( "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "sanitizedName" TEXT NOT NULL, "serverUrl" TEXT NOT NULL, "clientInfo" TEXT, + "clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC', "orgId" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -10,11 +16,21 @@ CREATE TABLE "McpServer" ( CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "McpServerToolCallCount" ( + "mcpServerId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName") +); + -- CreateTable CREATE TABLE "UserMcpServer" ( "userId" TEXT NOT NULL, "serverId" TEXT NOT NULL, - "name" TEXT NOT NULL, "tokens" TEXT, "tokensExpiresAt" TIMESTAMP(3), "codeVerifier" TEXT, @@ -28,12 +44,21 @@ CREATE TABLE "UserMcpServer" ( -- CreateIndex CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId"); +-- CreateIndex +CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName"); + +-- CreateIndex +CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId"); + -- CreateIndex CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state"); -- AddForeignKey ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 7833a0710c1c4bf9ff36ed2b6491ee34123fced2 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Fri, 29 May 2026 17:59:53 -0700 Subject: [PATCH 40/40] Address various review feedback --- .../(app)/chat/[id]/components/chatThreadPanel.tsx | 12 ++---------- .../workspaceAskAgent/workspaceAskAgentPage.tsx | 2 +- .../components/chatThread/chatThreadListItem.tsx | 2 +- .../chatThread/tools/toolSearchToolComponent.tsx | 1 - 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx index 0a83c51d8..33808b486 100644 --- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx @@ -20,14 +20,6 @@ interface ChatThreadPanelProps { chatName?: string; } -const normalizeDisabledMcpServerIds = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return []; - } - - return value.filter((id): id is string => typeof id === 'string'); -} - export const ChatThreadPanel = ({ languageModels, repos, @@ -53,7 +45,7 @@ export const ChatThreadPanel = ({ // Use the last user message to determine what repos, contexts, and MCP state we should select by default. const lastUserMessage = messages.findLast((message) => message.role === "user"); const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? []; - const defaultDisabledMcpServerIds = normalizeDisabledMcpServerIds(lastUserMessage?.metadata?.disabledMcpServerIds); + const defaultDisabledMcpServerIds = lastUserMessage?.metadata?.disabledMcpServerIds ?? []; const [selectedSearchScopes, setSelectedSearchScopes] = useState(defaultSelectedSearchScopes); const [disabledMcpServerIds, setDisabledMcpServerIds] = useState(defaultDisabledMcpServerIds); @@ -65,7 +57,7 @@ export const ChatThreadPanel = ({ try { setInputMessage(chatState.inputMessage); setSelectedSearchScopes(chatState.selectedSearchScopes); - setDisabledMcpServerIds(normalizeDisabledMcpServerIds(chatState.disabledMcpServerIds)); + setDisabledMcpServerIds(chatState.disabledMcpServerIds); } catch { console.error('Invalid chat state in session storage'); } finally { diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 27507932e..18fbc4411 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -418,7 +418,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback {/* Connectors section */}
-

Connectors

+

Connectors

Connectors are MCP servers that let Ask Agent use approved external tools alongside your indexed code.

diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index 4f39532b4..d05508081 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -426,7 +426,7 @@ const ChatThreadListItemComponent = forwardRef - ) : isNetworkActive ? ( + ) : (isTurnInProgress) ? (
{Array.from({ length: 3 }).map((_, index) => ( diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx index 58bcf4e90..545ed9b7f 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx @@ -27,7 +27,6 @@ export const ToolSearchToolComponent = ({ query, results }: ToolSearchToolCompon Searched connector tools: {query} {results.length} result{results.length === 1 ? '' : 's'} -