From 83a279a5494cdfa33757818004b96aa847224e11 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/15] 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 | 6 +
.../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 | 110 ++++++-
.../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 | 16 +-
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, 3444 insertions(+), 81 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 7e1af6be7..26bbdad3e 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -292,6 +292,8 @@ model Org {
chats Chat[]
+ mcpServers McpServer[]
+
license License?
/// Set the first time this instance is seen to be on a trial subscription.
@@ -409,6 +411,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
@@ -603,3 +608,72 @@ model OAuthToken {
createdAt DateTime @default(now())
lastUsedAt DateTime?
}
+
+/// 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 036655018..21d5e2b37 100644
--- a/packages/shared/src/env.server.ts
+++ b/packages/shared/src/env.server.ts
@@ -278,6 +278,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 8825a21c5..9ca986d6a 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 43ccb1a87..6bc248ce8 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
@@ -69,7 +69,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 574001e5f..3cf15df48 100644
--- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx
+++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx
@@ -40,11 +40,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;
@@ -53,6 +55,7 @@ export const ChatThreadPanel = ({
try {
setInputMessage(chatState.inputMessage);
setSelectedSearchScopes(chatState.selectedSearchScopes);
+ setDisabledMcpServerIds(chatState.disabledMcpServerIds);
} catch {
console.error('Invalid chat state in session storage');
} finally {
@@ -72,6 +75,8 @@ export const ChatThreadPanel = ({
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={setSelectedSearchScopes}
+ disabledMcpServerIds={disabledMcpServerIds}
+ onDisabledMcpServerIdsChange={setDisabledMcpServerIds}
isOwner={isOwner}
isAuthenticated={isAuthenticated}
chatName={chatName}
diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx
index 9d6b92381..99c2a5fb7 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";
import { LoginModal } from "@/app/components/loginModal";
@@ -28,6 +28,7 @@ export const LandingPageChatBox = ({
}: LandingPageChatBox) => {
const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated });
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;
@@ -36,7 +37,7 @@ export const LandingPageChatBox = ({
{
- createNewChatThread(children, selectedSearchScopes);
+ createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds);
}}
className="min-h-[50px]"
isRedirecting={isLoading}
@@ -56,6 +57,8 @@ export const LandingPageChatBox = ({
onSelectedSearchScopesChange={setSelectedSearchScopes}
isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
+ disabledMcpServerIds={disabledMcpServerIds}
+ onDisabledMcpServerIdsChange={setDisabledMcpServerIds}
/>
icon: "link" as const,
}
] : []),
+ ...(await hasEntitlement("oauth") ? [
+ {
+ title: "MCP Servers",
+ href: `/settings/mcpServers`,
+ }
+ ] : []),
],
},
];
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.
+
+
+
+
+
+ setIsCreateDialogOpen(true)}>
+
+ Add MCP Server
+
+
+
+
+ Add MCP Server
+
+
+
+ Cancel
+
+ {isCreating && }
+ Add
+
+
+
+
+
+
+ {/* 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 22c689278..5d11cfef4 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -29,6 +29,8 @@ import type {
SearchChatShareableMembersQueryParams,
SearchChatShareableMembersResponse,
} from "../(server)/ee/chat/[chatId]/searchMembers/route";
+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", {
@@ -214,4 +216,33 @@ export const listChats = async (queryParams: ListChatsQueryParams): Promise response.json());
return result as ListChatsResponse | 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 (
+
+
+
+ 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 f60d281b7..af0aee3cc 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';
@@ -47,6 +50,8 @@ interface ChatThreadProps {
searchContexts: SearchContextQuery[];
selectedSearchScopes: SearchScope[];
onSelectedSearchScopesChange: (items: SearchScope[]) => void;
+ disabledMcpServerIds: string[];
+ onDisabledMcpServerIdsChange: (ids: string[]) => void;
isOwner?: boolean;
isAuthenticated?: boolean;
chatName?: string;
@@ -61,6 +66,8 @@ export const ChatThread = ({
searchContexts,
selectedSearchScopes,
onSelectedSearchScopesChange,
+ disabledMcpServerIds,
+ onDisabledMcpServerIdsChange,
isOwner = true,
isAuthenticated = false,
chatName,
@@ -86,13 +93,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,
@@ -100,17 +160,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);
+ }
}
});
@@ -133,6 +204,7 @@ export const ChatThread = ({
body: {
selectedSearchScopes,
languageModel: selectedLanguageModel,
+ disabledMcpServerIds,
} satisfies AdditionalChatRequestParams,
});
@@ -162,6 +234,7 @@ export const ChatThread = ({
selectedLanguageModel,
_sendMessage,
selectedSearchScopes,
+ disabledMcpServerIds,
messages.length,
toast,
chatId,
@@ -231,13 +304,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();
} catch (error) {
console.error('Failed to restore pending message:', error);
}
- }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, scrollToBottom]);
+ }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, disabledMcpServerIds, scrollToBottom]);
// Track scroll position for history state restoration.
useEffect(() => {
@@ -319,13 +392,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, isAuthenticated, captureEvent, chatId, scrollToBottom]);
+ }, [sendMessage, selectedSearchScopes, disabledMcpServerIds, isAuthenticated, captureEvent, chatId, scrollToBottom]);
const onDuplicate = useCallback(async (newName: string): Promise => {
if (!defaultChatId) {
@@ -347,7 +420,8 @@ export const ChatThread = ({
}, [defaultChatId, toast, router, captureEvent]);
return (
- <>
+
+
{error && (
setIsErrorBannerVisible(false)}
/>
)}
+ setIsFailedMcpBannerVisible(false)}
+ />
@@ -480,6 +561,7 @@ export const ChatThread = ({
providers={loginWallProviders}
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
/>
- >
+
+
);
}
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 (
+
+ );
+};
\ 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 (
+
+
+
+
+
+ Agent wants to use {toolName} from {serverName}
+
+
+
+
+
+ Allow
+
+
+ Deny
+
+
+
+ {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 && (
+
+ Details
+
+
+ )}
+
+ {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 b84e9d922..5ae681b32 100644
--- a/packages/web/src/features/chat/constants.ts
+++ b/packages/web/src/features/chat/constants.ts
@@ -9,3 +9,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 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 63ead0249..7cf72a0ce 100644
--- a/packages/web/src/features/chat/useCreateNewChatThread.ts
+++ b/packages/web/src/features/chat/useCreateNewChatThread.ts
@@ -30,11 +30,11 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew
const hasRestoredPendingMessage = useRef(false);
const captureEvent = useCaptureEvent();
- const doCreateChat = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => {
+ const doCreateChat = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[], disabledMcpServerIds: string[]) => {
const text = slateContentToString(children);
const mentions = getAllMentionElements(children);
- const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes);
+ const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes, disabledMcpServerIds);
setIsLoading(true);
const response = await createChat({ source: 'sourcebot-web-client' });
@@ -49,6 +49,7 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew
setChatState({
inputMessage,
selectedSearchScopes,
+ disabledMcpServerIds,
});
const url = createPathWithQueryParams(`/chat/${response.id}`);
@@ -56,18 +57,18 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew
router.push(url);
}, [router, toast, setChatState]);
- const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => {
+ const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[], disabledMcpServerIds: string[]) => {
if (!isAuthenticated) {
const result = await getAskGhLoginWallData();
if (!isServiceError(result) && result.isEnabled) {
captureEvent('wa_askgh_login_wall_prompted', {});
- sessionStorage.setItem(PENDING_NEW_CHAT_KEY, JSON.stringify({ children, selectedSearchScopes }));
+ sessionStorage.setItem(PENDING_NEW_CHAT_KEY, JSON.stringify({ children, selectedSearchScopes, disabledMcpServerIds }));
setLoginWallState({ isOpen: true, providers: result.providers });
return;
}
}
- doCreateChat(children, selectedSearchScopes);
+ doCreateChat(children, selectedSearchScopes, disabledMcpServerIds);
}, [isAuthenticated, captureEvent, doCreateChat]);
// Restore pending message after OAuth redirect
@@ -85,11 +86,12 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew
sessionStorage.removeItem(PENDING_NEW_CHAT_KEY);
try {
- const { children, selectedSearchScopes } = JSON.parse(stored) as {
+ const { children, selectedSearchScopes, disabledMcpServerIds } = JSON.parse(stored) as {
children: Descendant[];
selectedSearchScopes: SearchScope[];
+ disabledMcpServerIds: string[];
};
- doCreateChat(children, selectedSearchScopes);
+ doCreateChat(children, selectedSearchScopes, disabledMcpServerIds ?? []);
} catch (error) {
console.error('Failed to restore pending message:', error);
}
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 7be7eb0ae..4aaba200f 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"
@@ -9062,6 +9097,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"
@@ -9274,7 +9310,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
@@ -18526,13 +18562,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"
@@ -23045,7 +23088,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 716bfcc654088f8652947198bf57a64be7a9bf11 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/15] 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 26bbdad3e..2e76416bf 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -411,7 +411,6 @@ model User {
/// claim baked into the JWT cookie at mint time.
sessionVersion Int @default(0)
- mcpServerCredentials McpServerCredential[]
userMcpServers UserMcpServer[]
createdAt DateTime @default(now())
@@ -623,7 +622,6 @@ model McpServer {
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
- credentials McpServerCredential[]
userMcpServers UserMcpServer[]
createdAt DateTime @default(now())
@@ -632,7 +630,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
@@ -642,22 +641,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?
@@ -674,6 +657,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 ddb5d3f3fbdfefa4937265d1a7fd3a2325b2960e 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/15] 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 f52286a95ff7c58cc3af041d4caa96c43b29ffdc 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/15] 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 b64a8b895dba82c6acffcbb75cb0a2c8053a819a 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/15] 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 2e76416bf..9016a813e 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -611,8 +611,10 @@ model OAuthToken {
/// 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 }
@@ -628,10 +630,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
@@ -639,8 +642,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."}
-
-
- setIsCreateDialogOpen(true)}>
-
- Add MCP Server
-
-
-
-
- Add MCP Server
-
-
-
- Cancel
-
- {isCreating && }
- Add
+ {canManageMcpServers && (
+
+
+ setIsCreateDialogOpen(true)}>
+
+ Add MCP Server
-
-
-
+
+
+
+ Add MCP Server
+
+
+
+ Cancel
+
+ {isCreating && }
+ Add
+
+
+
+
+ )}
{/* 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 && (
+
+
+
- {deletingServerId === server.id ? "Deleting..." : "Delete"}
-
-
-
-
+
+
+
+
+
+ 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 4317cbf81800a0f05741b7a800fe5bb1b529dd2c 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/15] 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 | 9 +-
.../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, 848 insertions(+), 186 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 9016a813e..a9463e008 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -659,5 +659,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 862298a61..9a560199e 100644
--- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx
@@ -16,6 +16,7 @@ import {
type LucideIcon,
PlugIcon,
ScrollTextIcon,
+ ServerIcon,
Settings2Icon,
ShieldIcon,
UserIcon,
@@ -32,6 +33,7 @@ const iconMap = {
"plug": PlugIcon,
"chart-area": ChartAreaIcon,
"scroll-text": ScrollTextIcon,
+ "server": ServerIcon,
"settings": Settings2Icon,
"user": UserIcon,
} satisfies Record;
diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx
index c6e9864d6..df535aba3 100644
--- a/packages/web/src/app/(app)/settings/layout.tsx
+++ b/packages/web/src/app/(app)/settings/layout.tsx
@@ -119,6 +119,13 @@ export const getSidebarNavGroups = async () =>
href: `/settings/analytics`,
icon: "chart-area" as const,
},
+ ...(await hasEntitlement("oauth") ? [
+ {
+ title: "MCP Configuration",
+ href: `/settings/mcpConfiguration`,
+ icon: "server" as const,
+ }
+ ] : []),
{
title: "License",
href: `/settings/license`,
@@ -129,4 +136,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/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
+
+
+
+ Cancel
+
+ {isCreating && }
+ Add
+
+
+
+
+
+
+ {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 && (
+
+
+
+ Open MCP Configuration
+
+
+ )}
+
+
+ );
+}
+
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 && (
-
-
- setIsCreateDialogOpen(true)}>
-
- Add MCP Server
-
-
-
-
- Add MCP Server
-
-
-
- Cancel
-
- {isCreating && }
- Add
-
-
-
-
- )}
+
+
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 5d11cfef4..b07aba391 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -31,6 +31,7 @@ import type {
} from "../(server)/ee/chat/[chatId]/searchMembers/route";
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", {
@@ -246,3 +247,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 b2fbb174d340c39ccb8e2de7ecaf436c6eb70228 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/15] 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 df535aba3..489063843 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`,
@@ -119,7 +125,7 @@ export const getSidebarNavGroups = async () =>
href: `/settings/analytics`,
icon: "chart-area" as const,
},
- ...(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
-
-
-
- Cancel
-
- {isCreating && }
- Add
+ {canCreateMcpServers ? (
+
+
+
+
-
-
-
+
+
+
+ Add MCP Server
+
+
+
+ Cancel
+
+ {isCreating && }
+ Add
+
+
+
+
+ ) : (
+
+
+
+ )}
{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 b18763907be77b9f825dcfa0d73ac4f351471e7c 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/15] 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
-
-
-
-
Name
-
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.
+
+
+
-
-
Server URL
-
setNewServerUrl(event.target.value)}
- placeholder="https://mcp.linear.app/mcp"
- />
+
+ Cancel
+
+ {isCreating && }
+ {isCreating ? "Checking..." : "Add"}
+
+
+
+
+
{
+ 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}
+
+ )}
+
+ Client ID
+ setClientId(event.target.value)}
+ placeholder="OAuth client ID"
+ />
+
+
+ Client Secret
+ setClientSecret(event.target.value)}
+ placeholder="OAuth client secret"
+ />
+
-
-
- Cancel
-
- {isCreating && }
- Add
-
-
-
-
+
+ Cancel
+
+ Add
+
+
+
+
+ >
) : (
diff --git a/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx b/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx
new file mode 100644
index 000000000..895f51f21
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/mcpConfiguration/prefabMcpServerPopover.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useMemo, useState } from "react";
+import {
+ Command,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { McpFavicon } from "@/ee/features/mcp/components/mcpFavicon";
+import {
+ getAvailablePrefabMcpServers,
+ type PrefabMcpServer,
+} from "@/ee/features/mcp/prefabMcpServers";
+import { getMcpFaviconUrl } from "@/ee/features/mcp/utils";
+import { PlusIcon } from "lucide-react";
+
+interface PrefabMcpServerPopoverProps {
+ configuredServerUrls: string[];
+ disabled?: boolean;
+ onSelectCustomUrl: () => void;
+ onSelectPrefabServer: (server: PrefabMcpServer) => void;
+}
+
+function getDisplayServerUrl(serverUrl: string) {
+ try {
+ const url = new URL(serverUrl);
+ return `${url.host}${url.pathname}${url.search}`.replace(/\/$/, "");
+ } catch {
+ return serverUrl;
+ }
+}
+
+export function PrefabMcpServerPopover({
+ configuredServerUrls,
+ disabled,
+ onSelectCustomUrl,
+ onSelectPrefabServer,
+}: PrefabMcpServerPopoverProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const availablePrefabServers = useMemo(() => (
+ getAvailablePrefabMcpServers(configuredServerUrls)
+ ), [configuredServerUrls]);
+
+ const filteredPrefabServers = useMemo(() => {
+ const normalizedSearch = search.trim().toLowerCase();
+
+ if (!normalizedSearch) {
+ return availablePrefabServers;
+ }
+
+ return availablePrefabServers.filter((server) => server.name.toLowerCase().includes(normalizedSearch));
+ }, [availablePrefabServers, search]);
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+
+ if (!open) {
+ setSearch("");
+ }
+ };
+
+ const handleSelectPrefabServer = (server: PrefabMcpServer) => {
+ handleOpenChange(false);
+ onSelectPrefabServer(server);
+ };
+
+ const handleSelectCustomUrl = () => {
+ handleOpenChange(false);
+ onSelectCustomUrl();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {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 ebae271231f5128a22872f1aff1523faa844d72c 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/15] 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 b60e32971c0be2c663d11172ec18fba10b39a5c9 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/15] 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 a9463e008..38c4b1e5d 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -330,6 +330,11 @@ enum OrgRole {
MEMBER
}
+enum McpServerClientInfoSource {
+ DYNAMIC
+ STATIC
+}
+
model UserToOrg {
joinedAt DateTime @default(now())
@@ -616,10 +621,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() {
Cancel
+ {isCreating && }
Add
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 24807f3d320b7017457424e12154bed3b46bffad 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/15] 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 03ac237e5a412f1a0aacbd54b42280ed36d1b177 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/15] 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 a0a9b07645c95d1bbe891077c72427de6845deea 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/15] 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 68a0afc5e820981dac966306e05b16bab6cee8aa 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/15] 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 b07aba391..1b6b8573c 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -219,7 +219,7 @@ export const listChats = async (queryParams: ListChatsQueryParams): 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 5ae681b32..c258e9951 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 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 35063f8fe6dbb157e51f7bab72ca732d6a25873b 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/15] 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"
+ />
+
+
+ setActiveTab("all")}
+ className={cn(
+ "px-3 py-1.5 text-xs font-medium rounded-sm transition-colors",
+ activeTab === "all"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ All
+ {servers.length}
+
+ setActiveTab("connected")}
+ className={cn(
+ "px-3 py-1.5 text-xs font-medium rounded-sm transition-colors",
+ activeTab === "connected"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Connected
+ {connectedServers.length}
+
+
+
+
{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
+ >
+ )}
+
+
+
+
+
+ Manage
+
+
+
+
+
+
+
+
+
+ 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 };
+}