diff --git a/docs/docs/features/ask/connectors.mdx b/docs/docs/features/ask/connectors.mdx index d962f25a0..2d743519a 100644 --- a/docs/docs/features/ask/connectors.mdx +++ b/docs/docs/features/ask/connectors.mdx @@ -32,7 +32,7 @@ If Ask Sourcebot needs to use a tool that requires approval, it pauses and asks An owner must add a connector before organization members can use it. To add a connector: -1. **Settings → Workspace → Ask Agent** +1. **Settings → Workspace → Ask Sourcebot** 2. Click **Add connector**. 3. Enter the MCP server URL and the name the organization will see for this server. @@ -50,9 +50,68 @@ then enter the OAuth client ID and client secret in Sourcebot. +## OAuth Scopes + +Owners can configure which OAuth scopes users authorize when connecting to a connector. + +Sourcebot checks the connector for discoverable scopes and shows them as options. You can also add custom scopes. + +
+ + OAuth scopes discovery + +
+ +Owners can change connector scopes at any time from **Settings → Workspace → Ask Sourcebot** and select **Edit OAuth scopes**. + +
+ + OAuth scopes editing + +
+ + +Changing connector scopes requires all users to re-authenticate with that connector. + + +## Tool Permissions + +Owners can configure how Ask Sourcebot may use each tool exposed by a connector. Changes take effect immediately and do not require users to re-authenticate. + +To edit tool permissions, open the connector menu from **Settings → Workspace → Ask Sourcebot** and select **Edit tool permissions**. + +
+ + Tools editing + +
+ +Each tool can be **Allowed**, require **Needs Approval**, or be **Blocked**. Allowed tools can run without pausing the chat, tools that need approval ask the user before running, and blocked tools are not available to Ask Sourcebot. + +Sourcebot groups tools by the hints reported by the connector. Tools with a read-only hint are grouped separately and tools without that hint are treated as write/delete tools. + +
+ + Tool permissions view + +
+ + ## Connecting -After an owner adds connectors for your organization, go to **Settings → Account → Ask Agent** to connect them. +After an owner adds connectors for your organization, go to **Settings → Account → Ask Sourcebot** to connect them. You can see all available connectors on this page. After you connect one, you can inspect the tools it provides and what each tool does. diff --git a/docs/images/connectors_edit_scopes.png b/docs/images/connectors_edit_scopes.png new file mode 100644 index 000000000..7b2837892 Binary files /dev/null and b/docs/images/connectors_edit_scopes.png differ diff --git a/docs/images/connectors_edit_tool_permissions.png b/docs/images/connectors_edit_tool_permissions.png new file mode 100644 index 000000000..f04b80629 Binary files /dev/null and b/docs/images/connectors_edit_tool_permissions.png differ diff --git a/docs/images/connectors_oauth_scopes.png b/docs/images/connectors_oauth_scopes.png new file mode 100644 index 000000000..1fa337aa8 Binary files /dev/null and b/docs/images/connectors_oauth_scopes.png differ diff --git a/docs/images/connectors_tool_permissions.png b/docs/images/connectors_tool_permissions.png new file mode 100644 index 000000000..e8b70456a Binary files /dev/null and b/docs/images/connectors_tool_permissions.png differ diff --git a/packages/backend/src/redis.ts b/packages/backend/src/redis.ts index 115e1d2cb..ea9e5f470 100644 --- a/packages/backend/src/redis.ts +++ b/packages/backend/src/redis.ts @@ -1,50 +1,3 @@ -import { env } from "@sourcebot/shared"; -import { Redis } from 'ioredis'; -import fs from "fs"; +import { createRedisClient } from "@sourcebot/shared"; -const buildTlsOptions = (): Record => { - if (env.REDIS_TLS_ENABLED !== "true" && !env.REDIS_URL.startsWith("rediss://")) { - return {}; - } - - return { - tls: { - ca: env.REDIS_TLS_CA_PATH - ? fs.readFileSync(env.REDIS_TLS_CA_PATH) - : undefined, - cert: env.REDIS_TLS_CERT_PATH - ? fs.readFileSync(env.REDIS_TLS_CERT_PATH) - : undefined, - key: env.REDIS_TLS_KEY_PATH - ? fs.readFileSync(env.REDIS_TLS_KEY_PATH) - : undefined, - ...(env.REDIS_TLS_REJECT_UNAUTHORIZED - ? { rejectUnauthorized: env.REDIS_TLS_REJECT_UNAUTHORIZED === 'true' } - : {}), - ...(env.REDIS_TLS_SERVERNAME - ? { servername: env.REDIS_TLS_SERVERNAME } - : {}), - ...(env.REDIS_TLS_CHECK_SERVER_IDENTITY === "false" - ? { checkServerIdentity: () => undefined } - : {}), - ...(env.REDIS_TLS_SECURE_PROTOCOL - ? { secureProtocol: env.REDIS_TLS_SECURE_PROTOCOL } - : {}), - ...(env.REDIS_TLS_CIPHERS ? { ciphers: env.REDIS_TLS_CIPHERS } : {}), - ...(env.REDIS_TLS_HONOR_CIPHER_ORDER - ? { - honorCipherOrder: env.REDIS_TLS_HONOR_CIPHER_ORDER === "true", - } - : {}), - ...(env.REDIS_TLS_KEY_PASSPHRASE - ? { passphrase: env.REDIS_TLS_KEY_PASSPHRASE } - : {}), - }, - }; -}; - - -export const redis = new Redis(env.REDIS_URL, { - maxRetriesPerRequest: null, - ...buildTlsOptions(), -}); +export const redis = createRedisClient(); diff --git a/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql b/packages/db/prisma/migrations/20260603172622_add_mcp_connectors/migration.sql similarity index 65% rename from packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql rename to packages/db/prisma/migrations/20260603172622_add_mcp_connectors/migration.sql index fb82482c0..e92f0ded8 100644 --- a/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql +++ b/packages/db/prisma/migrations/20260603172622_add_mcp_connectors/migration.sql @@ -1,6 +1,9 @@ -- CreateEnum CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC'); +-- CreateEnum +CREATE TYPE "McpServerToolPermission" AS ENUM ('ALLOWED', 'NEEDS_APPROVAL', 'DISABLED'); + -- CreateTable CREATE TABLE "McpServer" ( "id" TEXT NOT NULL, @@ -17,14 +20,26 @@ CREATE TABLE "McpServer" ( ); -- CreateTable -CREATE TABLE "McpServerToolCallCount" ( +CREATE TABLE "McpServerOAuthScope" ( + "mcpServerId" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServerOAuthScope_pkey" PRIMARY KEY ("mcpServerId","scope") +); + +-- CreateTable +CREATE TABLE "McpServerTool" ( "mcpServerId" TEXT NOT NULL, "toolName" TEXT NOT NULL, - "count" INTEGER NOT NULL DEFAULT 0, + "callCount" INTEGER NOT NULL DEFAULT 0, + "permission" "McpServerToolPermission" NOT NULL DEFAULT 'NEEDS_APPROVAL', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName") + CONSTRAINT "McpServerTool_pkey" PRIMARY KEY ("mcpServerId","toolName") ); -- CreateTable @@ -57,7 +72,10 @@ CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state"); ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "McpServerOAuthScope" ADD CONSTRAINT "McpServerOAuthScope_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "McpServerTool" ADD CONSTRAINT "McpServerTool_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e0371e56c..f18ebf151 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -347,6 +347,12 @@ enum McpServerClientInfoSource { STATIC } +enum McpServerToolPermission { + ALLOWED + NEEDS_APPROVAL + DISABLED +} + model UserToOrg { joinedAt DateTime @default(now()) @@ -684,7 +690,8 @@ model McpServer { orgId Int userMcpServers UserMcpServer[] - toolCallCounts McpServerToolCallCount[] + tools McpServerTool[] + oauthScopes McpServerOAuthScope[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -693,12 +700,26 @@ model McpServer { @@unique([orgId, sanitizedName]) } -/// Lifetime tool call counters for an MCP server. -model McpServerToolCallCount { +/// OAuth scope configuration for an MCP server. +model McpServerOAuthScope { + mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade) + mcpServerId String + scope String + enabled Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([mcpServerId, scope]) +} + +/// Tool metadata for an MCP server. +model McpServerTool { mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade) mcpServerId String toolName String - count Int @default(0) + callCount Int @default(0) + permission McpServerToolPermission @default(NEEDS_APPROVAL) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/shared/package.json b/packages/shared/package.json index e0a446078..6ded4e24b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -18,6 +18,7 @@ "@sourcebot/schemas": "workspace:*", "@t3-oss/env-core": "^0.13.10", "ajv": "^8.17.1", + "ioredis": "^5.4.2", "micromatch": "^4.0.8", "strip-json-comments": "^5.0.1", "triple-beam": "^1.4.1", diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index ce905c773..38c42e319 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -65,6 +65,9 @@ export { export { getSMTPConnectionURL, } from "./smtp.js"; +export { + createRedisClient, +} from "./redis.js"; export { SOURCEBOT_VERSION, } from "./version.js"; @@ -73,4 +76,4 @@ export { formatVersion, compareVersions, } from "./versionUtils.js"; -export type { Version } from "./versionUtils.js"; \ No newline at end of file +export type { Version } from "./versionUtils.js"; diff --git a/packages/shared/src/redis.ts b/packages/shared/src/redis.ts new file mode 100644 index 000000000..c016aa966 --- /dev/null +++ b/packages/shared/src/redis.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import { Redis } from "ioredis"; +import { env } from "./env.server.js"; + +const buildTlsOptions = (): Record => { + if (env.REDIS_TLS_ENABLED !== "true" && !env.REDIS_URL.startsWith("rediss://")) { + return {}; + } + + return { + tls: { + ca: env.REDIS_TLS_CA_PATH + ? fs.readFileSync(env.REDIS_TLS_CA_PATH) + : undefined, + cert: env.REDIS_TLS_CERT_PATH + ? fs.readFileSync(env.REDIS_TLS_CERT_PATH) + : undefined, + key: env.REDIS_TLS_KEY_PATH + ? fs.readFileSync(env.REDIS_TLS_KEY_PATH) + : undefined, + ...(env.REDIS_TLS_REJECT_UNAUTHORIZED + ? { rejectUnauthorized: env.REDIS_TLS_REJECT_UNAUTHORIZED === "true" } + : {}), + ...(env.REDIS_TLS_SERVERNAME + ? { servername: env.REDIS_TLS_SERVERNAME } + : {}), + ...(env.REDIS_TLS_CHECK_SERVER_IDENTITY === "false" + ? { checkServerIdentity: () => undefined } + : {}), + ...(env.REDIS_TLS_SECURE_PROTOCOL + ? { secureProtocol: env.REDIS_TLS_SECURE_PROTOCOL } + : {}), + ...(env.REDIS_TLS_CIPHERS ? { ciphers: env.REDIS_TLS_CIPHERS } : {}), + ...(env.REDIS_TLS_HONOR_CIPHER_ORDER + ? { + honorCipherOrder: env.REDIS_TLS_HONOR_CIPHER_ORDER === "true", + } + : {}), + ...(env.REDIS_TLS_KEY_PASSPHRASE + ? { passphrase: env.REDIS_TLS_KEY_PASSPHRASE } + : {}), + }, + }; +}; + +export const createRedisClient = () => new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + ...buildTlsOptions(), +}); diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 527c03817..3ea8d56dd 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -197,7 +197,7 @@ export default async function Layout(props: LayoutProps) { latestVersion={latestVersion} /> -
+
{children}
@@ -211,4 +211,4 @@ export default async function Layout(props: LayoutProps) { ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/mcpToolPermissionsPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/mcpToolPermissionsPage.tsx new file mode 100644 index 000000000..931336e2f --- /dev/null +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/mcpToolPermissionsPage.tsx @@ -0,0 +1,616 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { getMcpServerToolPermissions } from '@/app/api/(client)/client'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useToast } from '@/components/hooks/use-toast'; +import { updateMcpServerToolPermissions } from '@/ee/features/chat/mcp/actions'; +import { + MCP_SERVER_TOOL_PERMISSION_OPTIONS, + ToolHintBadges, + getMcpServerToolPermissionDisplay, +} from '@/ee/features/chat/mcp/mcpToolPermissionDisplay'; +import { invalidateMcpConfigurationQueries, mcpQueryKeys } from '@/ee/features/chat/mcp/queryKeys'; +import type { + GetMcpServerToolPermissionsResponse, + McpServerToolPermission, + McpServerToolPermissionEntry, +} from '@/ee/features/chat/mcp/types'; +import { formatCount, pluralize } from '@/features/chat/mcp/utils'; +import { cn, isServiceError } from '@/lib/utils'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + ArrowLeftIcon, + ChevronRightIcon, + ChevronDownIcon, + Loader2Icon, + RefreshCwIcon, + SearchIcon, + SlidersHorizontalIcon, +} from 'lucide-react'; + +const EMPTY_TOOLS: McpServerToolPermissionEntry[] = []; + +type PermissionFilter = 'ALL' | McpServerToolPermission; + +interface McpToolPermissionsPageProps { + serverId: string; +} + +interface ToolGroup { + id: 'read-only' | 'write-delete'; + label: string; + tools: McpServerToolPermissionEntry[]; +} + +interface ToolPermissionChange { + toolName: string; + permission: McpServerToolPermission; +} + +function getToolDisplayName(tool: McpServerToolPermissionEntry) { + return tool.title ?? tool.toolName; +} + +function getVisiblePermissionSummary( + tools: McpServerToolPermissionEntry[], +) { + const firstTool = tools[0]; + if (firstTool) { + const firstPermission = firstTool.permission; + const isSamePermission = tools.every((tool) => ( + tool.permission === firstPermission + )); + + if (isSamePermission) { + const display = getMcpServerToolPermissionDisplay(firstPermission); + return { + label: display.label, + icon: display.icon, + }; + } + } + + return { label: 'Custom', icon: SlidersHorizontalIcon }; +} + +function PermissionCount({ + permission, + count, +}: { + permission: McpServerToolPermission; + count: number; +}) { + const display = getMcpServerToolPermissionDisplay(permission); + const Icon = display.icon; + + return ( + + + + + {formatCount(count)} + + + {display.label} + + ); +} + +function PermissionToggleGroup({ + toolName, + value, + onChange, +}: { + toolName: string; + value: McpServerToolPermission; + onChange: (permission: McpServerToolPermission) => void; +}) { + return ( + { + if (nextValue) { + onChange(nextValue as McpServerToolPermission); + } + }} + className="justify-end" + > + {MCP_SERVER_TOOL_PERMISSION_OPTIONS.map((option) => { + const Icon = option.icon; + const isSelected = value === option.value; + + return ( + + + + + + + {option.label} + + ); + })} + + ); +} + +function ToolRow({ + tool, + permission, + onPermissionChange, +}: { + tool: McpServerToolPermissionEntry; + permission: McpServerToolPermission; + onPermissionChange: (permission: McpServerToolPermission) => void; +}) { + const displayName = getToolDisplayName(tool); + const [isDescriptionOpen, setIsDescriptionOpen] = useState(false); + const hasDescription = Boolean(tool.description); + + return ( +
+
+ {hasDescription ? ( + + + + + {isDescriptionOpen ? 'Hide description' : 'Show description'} + + ) : ( +
+ )} +
+
+ {displayName} + {tool.title && tool.title !== tool.toolName && ( + {tool.toolName} + )} + +
+ {hasDescription && isDescriptionOpen && ( +

{tool.description}

+ )} +

+ {formatCount(tool.callCount)} {pluralize(tool.callCount, 'call')} + {!tool.discovered && - not in latest discovery} +

+
+
+
+ +
+
+ ); +} + +export function McpToolPermissionsPage({ serverId }: McpToolPermissionsPageProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [searchInput, setSearchInput] = useState(''); + const [permissionFilter, setPermissionFilter] = useState('ALL'); + const [openGroups, setOpenGroups] = useState>({ + 'read-only': true, + 'write-delete': true, + }); + const [pendingSaveCount, setPendingSaveCount] = useState(0); + const isSaving = pendingSaveCount > 0; + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: mcpQueryKeys.toolPermissions(serverId), + queryFn: async () => { + const result = await getMcpServerToolPermissions(serverId); + if (isServiceError(result)) { + throw new Error(result.message); + } + return result; + }, + }); + + const tools = data?.tools ?? EMPTY_TOOLS; + const getCachedToolPermissions = () => { + const current = queryClient.getQueryData( + mcpQueryKeys.toolPermissions(serverId), + ); + + return new Map((current?.tools ?? tools).map((tool) => [tool.toolName, tool.permission] as const)); + }; + const updateCachedPermissionChanges = ( + changes: ToolPermissionChange[], + getPermission: ( + tool: McpServerToolPermissionEntry, + change: ToolPermissionChange, + ) => McpServerToolPermission | undefined, + ) => { + const changesByToolName = new Map(changes.map((change) => [change.toolName, change] as const)); + + queryClient.setQueryData( + mcpQueryKeys.toolPermissions(serverId), + (current) => { + if (!current) { + return current; + } + + let didChange = false; + const tools = current.tools.map((tool) => { + const change = changesByToolName.get(tool.toolName); + if (!change) { + return tool; + } + + const permission = getPermission(tool, change); + if (!permission || permission === tool.permission) { + return tool; + } + + didChange = true; + return { + ...tool, + permission, + }; + }); + + return didChange + ? { + ...current, + tools, + } + : current; + }, + ); + }; + const permissionCounts = useMemo(() => { + const counts: Record = { + ALLOWED: 0, + NEEDS_APPROVAL: 0, + DISABLED: 0, + }; + + for (const tool of tools) { + counts[tool.permission] += 1; + } + + return counts; + }, [tools]); + + const filteredTools = useMemo(() => { + const query = searchInput.trim().toLowerCase(); + + return tools.filter((tool) => { + if (permissionFilter !== 'ALL' && tool.permission !== permissionFilter) { + return false; + } + + if (!query) { + return true; + } + + return [ + tool.toolName, + tool.title, + tool.description, + ].some((value) => value?.toLowerCase().includes(query)); + }); + }, [permissionFilter, searchInput, tools]); + const filteredToolGroups = useMemo(() => [ + { + id: 'read-only', + label: 'Read-only tools', + tools: filteredTools.filter((tool) => tool.annotations?.readOnlyHint === true), + }, + { + id: 'write-delete', + label: 'Write/delete tools', + tools: filteredTools.filter((tool) => tool.annotations?.readOnlyHint !== true), + }, + ].filter((group) => group.tools.length > 0), [filteredTools]); + + const rollbackPermissionChanges = ( + changes: ToolPermissionChange[], + previousPermissions: Record, + ) => { + updateCachedPermissionChanges(changes, (tool, change) => ( + tool.permission === change.permission + ? previousPermissions[change.toolName] + : undefined + )); + }; + + const savePermissionChanges = async ( + changes: ToolPermissionChange[], + previousPermissions: Record, + ) => { + setPendingSaveCount((count) => count + 1); + try { + const result = await updateMcpServerToolPermissions(serverId, changes); + + if (isServiceError(result)) { + rollbackPermissionChanges(changes, previousPermissions); + toast({ title: 'Error', description: `Failed to update tool permissions: ${result.message}`, variant: 'destructive' }); + return; + } + + void invalidateMcpConfigurationQueries(queryClient).catch(() => undefined); + } catch { + rollbackPermissionChanges(changes, previousPermissions); + toast({ title: 'Error', description: 'Failed to update tool permissions.', variant: 'destructive' }); + } finally { + setPendingSaveCount((count) => Math.max(0, count - 1)); + } + }; + + const handlePermissionChange = (tool: McpServerToolPermissionEntry, permission: McpServerToolPermission) => { + const currentPermission = getCachedToolPermissions().get(tool.toolName) ?? tool.permission; + if (currentPermission === permission) { + return; + } + + updateCachedPermissionChanges( + [{ toolName: tool.toolName, permission }], + (_tool, change) => change.permission, + ); + void savePermissionChanges( + [{ toolName: tool.toolName, permission }], + { [tool.toolName]: currentPermission }, + ); + }; + + const handleApplyToTools = (toolsToUpdate: McpServerToolPermissionEntry[], permission: McpServerToolPermission) => { + const changes: ToolPermissionChange[] = []; + const previousPermissions: Record = {}; + const cachedPermissionsByToolName = getCachedToolPermissions(); + for (const tool of toolsToUpdate) { + const currentPermission = cachedPermissionsByToolName.get(tool.toolName) ?? tool.permission; + if (currentPermission === permission) { + continue; + } + changes.push({ toolName: tool.toolName, permission }); + previousPermissions[tool.toolName] = currentPermission; + } + if (changes.length === 0) { + return; + } + + updateCachedPermissionChanges(changes, (_tool, change) => change.permission); + void savePermissionChanges(changes, previousPermissions); + }; + + const handleGroupOpenChange = (groupId: ToolGroup['id']) => { + setOpenGroups((current) => ({ + ...current, + [groupId]: !current[groupId], + })); + }; + + if (isLoading) { + return ( +
+ +
+ + +
+ + +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+ ); + } + + if (isError || !data) { + return ( +
+ + + Back to Ask Sourcebot + +
+

Tool permissions

+

Unable to load tool permissions.

+
+ +
+ ); + } + + const metadataStatus = data.metadataStatus; + + return ( +
+ + + Back to Ask Sourcebot + + +
+
+
+ {data.server.faviconUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +

{data.server.name}

+
+

{data.server.serverUrl}

+
+ {formatCount(tools.length)} {pluralize(tools.length, 'tool')} + + + +
+
+ {isSaving && ( +
+ + Saving +
+ )} +
+ + + + {metadataStatus.status !== 'available' && ( +
+ {metadataStatus.status === 'not_connected' + ? "Connect as an admin to refresh this connector's tool list." + : 'The latest tool list could not be refreshed.'} +
+ )} + +
+
+ + setSearchInput(event.target.value)} + placeholder="Search tools" + className="pl-8" + /> +
+
+ +
+
+ +
+ {filteredTools.length === 0 ? ( +
+ {tools.length === 0 ? 'No tools discovered yet.' : 'No matching tools.'} +
+ ) : ( + filteredToolGroups.map((group) => { + const permissionSummary = getVisiblePermissionSummary(group.tools); + const PermissionSummaryIcon = permissionSummary.icon; + + return ( +
+
+ + + + + + + {MCP_SERVER_TOOL_PERMISSION_OPTIONS.map((option) => { + const Icon = option.icon; + + return ( + handleApplyToTools(group.tools, option.value)}> + + {option.label} + + ); + })} + + +
+ {openGroups[group.id] && group.tools.map((tool) => ( + handlePermissionChange(tool, permission)} + /> + ))} +
+ ); + }) + )} +
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/page.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/page.tsx new file mode 100644 index 000000000..54556daf7 --- /dev/null +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/connectors/[serverId]/tools/page.tsx @@ -0,0 +1,19 @@ +import { authenticatedPage } from '@/middleware/authenticatedPage'; +import { OrgRole } from '@sourcebot/db'; +import { notFound } from 'next/navigation'; +import { McpToolPermissionsPage } from './mcpToolPermissionsPage'; + +interface PageProps extends Record { + params: Promise<{ + serverId: string; + }>; +} + +export default authenticatedPage(async (_context, { params }) => { + const { serverId } = await params; + if (!serverId) { + return notFound(); + } + + return ; +}, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 74096c44a..08879ee85 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -20,15 +21,17 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/chat/mcp/actions"; +import { Textarea } from "@/components/ui/textarea"; +import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, updateMcpServerOAuthScopes } from "@/ee/features/chat/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton"; import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; +import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes } from "@/ee/features/chat/mcp/oauthScopeUtils"; import { pluralize } from "@/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangleIcon, CableIcon, CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { AlertTriangleIcon, CableIcon, CopyIcon, KeyRoundIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react"; import { PrefabConnectorPopover } from "@/ee/features/chat/mcp/components/prefabConnectorPopover"; import Markdown from "react-markdown"; import { getStaticOAuthDescription, type PrefabMcpServer } from "@/ee/features/chat/mcp/prefabMcpServers"; @@ -42,6 +45,10 @@ function clearCallbackParams() { window.history.replaceState({}, '', url.toString()); } +function oauthScopesEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((scope, index) => scope === b[index]); +} + interface WorkspaceAskAgentPageProps { callbackStatus?: string; callbackServer?: string; @@ -54,6 +61,137 @@ type WorkspaceConnectorStatus = { isAuthExpired: boolean; }; +type PendingConnectorServer = { + name: string; + serverUrl: string; + discoveredOAuthScopes: string[]; +}; + +interface OAuthScopesInputProps { + discoveredOAuthScopes: string[]; + selectedOAuthScopes: string[]; + customOAuthScopeInput: string; + customOAuthScopesInputId: string; + onSelectedOAuthScopesChange: (oauthScopes: string[]) => void; + onCustomOAuthScopeInputChange: (value: string) => void; + onRemoveOAuthScope?: (scope: string) => void; +} + +function OAuthScopesInput({ + discoveredOAuthScopes, + selectedOAuthScopes, + customOAuthScopeInput, + customOAuthScopesInputId, + onSelectedOAuthScopesChange, + onCustomOAuthScopeInputChange, + onRemoveOAuthScope, +}: OAuthScopesInputProps) { + const [oauthScopeSearchInput, setOAuthScopeSearchInput] = useState(""); + const selectedOAuthScopeSet = new Set(selectedOAuthScopes); + const requestedOAuthScopes = getMcpRequestedOAuthScopes(selectedOAuthScopes, customOAuthScopeInput); + const filteredOAuthScopes = useMemo(() => { + const query = oauthScopeSearchInput.trim().toLowerCase(); + if (!query) { + return discoveredOAuthScopes; + } + + return discoveredOAuthScopes.filter((scope) => scope.toLowerCase().includes(query)); + }, [discoveredOAuthScopes, oauthScopeSearchInput]); + + const handleCheckedChange = (scope: string, checked: boolean) => { + onSelectedOAuthScopesChange(checked + ? normalizeMcpRequestedOAuthScopes([...selectedOAuthScopes, scope]) + : selectedOAuthScopes.filter((selectedScope) => selectedScope !== scope)); + }; + + const handleSelectAll = () => { + onSelectedOAuthScopesChange(normalizeMcpRequestedOAuthScopes([...selectedOAuthScopes, ...filteredOAuthScopes])); + }; + + const handleClear = () => { + onSelectedOAuthScopesChange([]); + onCustomOAuthScopeInputChange(""); + }; + + return ( +
+
+
+ +

{requestedOAuthScopes.length} requested

+
+
+ {discoveredOAuthScopes.length > 0 && ( + + )} + +
+
+ + {discoveredOAuthScopes.length > 0 && ( +
+ setOAuthScopeSearchInput(event.target.value)} + placeholder="Search scopes" + className="h-9" + /> +
+ {filteredOAuthScopes.length > 0 ? ( + filteredOAuthScopes.map((scope) => ( +
+ + {onRemoveOAuthScope && ( + + )} +
+ )) + ) : ( +
+ No matching scopes +
+ )} +
+
+ )} + +
+ +