From 9150c5bce7d4d58e015bfea956ddc065949c8e39 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Sun, 31 May 2026 13:18:14 -0700 Subject: [PATCH 01/14] Add scopes as part of the prisma schema --- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 4 + .../src/ee/features/chat/mcp/actions.test.ts | 154 +++++++++++++++++- .../web/src/ee/features/chat/mcp/actions.ts | 35 ++++ .../web/src/ee/features/chat/mcp/mcpScopes.ts | 93 +++++++++++ 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20260531194500_add_mcp_requested_scopes/migration.sql create mode 100644 packages/web/src/ee/features/chat/mcp/mcpScopes.ts diff --git a/packages/db/prisma/migrations/20260531194500_add_mcp_requested_scopes/migration.sql b/packages/db/prisma/migrations/20260531194500_add_mcp_requested_scopes/migration.sql new file mode 100644 index 000000000..b37dde129 --- /dev/null +++ b/packages/db/prisma/migrations/20260531194500_add_mcp_requested_scopes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "McpServer" ADD COLUMN "requestedScopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e0371e56c..eb26a7578 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -680,6 +680,10 @@ model McpServer { clientInfo String? clientInfoSource McpServerClientInfoSource @default(DYNAMIC) + /// OAuth scopes requested when users authorize this connector. + /// Stored as opaque, provider-defined scope tokens. + requestedScopes String[] @default([]) + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int diff --git a/packages/web/src/ee/features/chat/mcp/actions.test.ts b/packages/web/src/ee/features/chat/mcp/actions.test.ts index 6582f53dd..7c61106db 100644 --- a/packages/web/src/ee/features/chat/mcp/actions.test.ts +++ b/packages/web/src/ee/features/chat/mcp/actions.test.ts @@ -16,12 +16,15 @@ const mocks = vi.hoisted(() => ({ }, captureEvent: vi.fn(), unsafePrisma: { + $transaction: vi.fn(), mcpServer: { delete: vi.fn(), findFirst: vi.fn(), + update: vi.fn(), }, userMcpServer: { deleteMany: vi.fn(), + updateMany: vi.fn(), }, }, })); @@ -45,7 +48,7 @@ vi.mock('@/lib/posthog', () => ({ captureEvent: mocks.captureEvent, })); -const { createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, disconnectMcpServer } = await import('./actions'); +const { createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, disconnectMcpServer, updateMcpServerScopes } = await import('./actions'); function createPrismaMock() { return { @@ -88,6 +91,7 @@ function createStaticOAuthRequest(overrides: Partial<{ beforeEach(() => { vi.clearAllMocks(); + mocks.unsafePrisma.$transaction.mockImplementation((callback: (tx: unknown) => unknown) => callback(mocks.unsafePrisma)); mocks.hasEntitlement.mockResolvedValue(true); mocks.encryptOAuthToken.mockImplementation((text: string | null | undefined) => text ? `encrypted:${text}` : undefined); mocks.env.AUTH_URL = 'https://sourcebot.example.com'; @@ -333,6 +337,154 @@ describe('createStaticOAuthMcpServer', () => { }); }); +describe('updateMcpServerScopes', () => { + test('owners update static connector scopes and invalidate saved user tokens', async () => { + setAuthContext(OrgRole.OWNER); + mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue({ + id: 'server-1', + requestedScopes: ['search:read.public'], + clientInfoSource: McpServerClientInfoSource.STATIC, + }); + mocks.unsafePrisma.mcpServer.update.mockResolvedValue({ id: 'server-1' }); + mocks.unsafePrisma.userMcpServer.updateMany.mockResolvedValue({ count: 2 }); + + const result = await updateMcpServerScopes(' server-1 ', [ + ' chat:write ', + 'files:read', + 'chat:write', + ]); + + expect(result).toEqual({ + success: true, + requestedScopes: ['chat:write', 'files:read'], + invalidatedConnectionCount: 2, + }); + expect(mocks.unsafePrisma.mcpServer.findFirst).toHaveBeenCalledWith({ + where: { + id: 'server-1', + orgId: 1, + }, + select: { + id: true, + requestedScopes: true, + clientInfoSource: true, + }, + }); + expect(mocks.unsafePrisma.mcpServer.update).toHaveBeenCalledWith({ + where: { id: 'server-1' }, + data: { + requestedScopes: ['chat:write', 'files:read'], + }, + }); + expect(mocks.unsafePrisma.userMcpServer.updateMany).toHaveBeenCalledWith({ + where: { serverId: 'server-1' }, + data: { + tokens: null, + tokensExpiresAt: null, + codeVerifier: null, + state: null, + }, + }); + }); + + test('clears dynamic client registration when dynamic connector scopes change', async () => { + setAuthContext(OrgRole.OWNER); + mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue({ + id: 'server-1', + requestedScopes: [], + clientInfoSource: McpServerClientInfoSource.DYNAMIC, + }); + mocks.unsafePrisma.mcpServer.update.mockResolvedValue({ id: 'server-1' }); + mocks.unsafePrisma.userMcpServer.updateMany.mockResolvedValue({ count: 1 }); + + await expect(updateMcpServerScopes('server-1', ['repo'])).resolves.toMatchObject({ + success: true, + invalidatedConnectionCount: 1, + }); + + expect(mocks.unsafePrisma.mcpServer.update).toHaveBeenCalledWith({ + where: { id: 'server-1' }, + data: { + requestedScopes: ['repo'], + clientInfo: null, + }, + }); + }); + + test('does not invalidate tokens when normalized scopes are unchanged', async () => { + setAuthContext(OrgRole.OWNER); + mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue({ + id: 'server-1', + requestedScopes: ['chat:write', 'files:read'], + clientInfoSource: McpServerClientInfoSource.STATIC, + }); + + const result = await updateMcpServerScopes('server-1', [ + 'files:read', + 'chat:write', + 'files:read', + ]); + + expect(result).toEqual({ + success: true, + requestedScopes: ['chat:write', 'files:read'], + invalidatedConnectionCount: 0, + }); + expect(mocks.unsafePrisma.mcpServer.update).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.updateMany).not.toHaveBeenCalled(); + }); + + test('rejects invalid OAuth scope tokens', async () => { + setAuthContext(OrgRole.OWNER); + + const result = await updateMcpServerScopes('server-1', ['bad scope']); + + expect(result).toMatchObject({ + statusCode: 400, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + }); + expect(mocks.unsafePrisma.$transaction).not.toHaveBeenCalled(); + }); + + test('returns not found when updating a missing connector', async () => { + setAuthContext(OrgRole.OWNER); + mocks.unsafePrisma.mcpServer.findFirst.mockResolvedValue(null); + + const result = await updateMcpServerScopes('server-1', ['chat:write']); + + expect(result).toMatchObject({ + statusCode: 404, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + }); + expect(mocks.unsafePrisma.mcpServer.update).not.toHaveBeenCalled(); + expect(mocks.unsafePrisma.userMcpServer.updateMany).not.toHaveBeenCalled(); + }); + + test('members cannot update connector scopes', async () => { + setAuthContext(OrgRole.MEMBER); + + const result = await updateMcpServerScopes('server-1', ['chat:write']); + + expect(result).toMatchObject({ + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(mocks.unsafePrisma.$transaction).not.toHaveBeenCalled(); + }); + + test('owners cannot update connector scopes when Ask Agent is unavailable', async () => { + setAuthContext(OrgRole.OWNER); + mocks.hasEntitlement.mockResolvedValue(false); + + const result = await updateMcpServerScopes('server-1', ['chat:write']); + + expect(result).toMatchObject({ + statusCode: 403, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + }); + expect(mocks.unsafePrisma.$transaction).not.toHaveBeenCalled(); + }); +}); + describe('deleteMcpServer', () => { test('owners delete through the narrowly scoped unsafe client and track the removal', async () => { setAuthContext(OrgRole.OWNER); diff --git a/packages/web/src/ee/features/chat/mcp/actions.ts b/packages/web/src/ee/features/chat/mcp/actions.ts index 9602d79e7..697465d23 100644 --- a/packages/web/src/ee/features/chat/mcp/actions.ts +++ b/packages/web/src/ee/features/chat/mcp/actions.ts @@ -18,14 +18,28 @@ import { encryptOAuthToken, env } from '@sourcebot/shared'; import { captureEvent } from '@/lib/posthog'; import { getMcpAuthMode } from './analytics'; import type { McpConnectorEntryPoint } from '@/lib/posthogEvents'; +import { + updateMcpServerRequestedScopes, + type UpdateMcpServerScopesResponse, +} from './mcpScopes'; const MCP_DCR_DISCOVERY_TIMEOUT_MS = Math.min(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS, 10000); +const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; 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), }); +const updateMcpServerScopesSchema = z.object({ + serverId: z.string().trim().min(1), + scopes: z.array( + z.string() + .trim() + .min(1) + .regex(OAUTH_SCOPE_TOKEN_REGEX, 'Scope must be a valid OAuth scope token.'), + ).max(200), +}); export type CreateStaticOAuthMcpServerRequest = z.infer; @@ -244,6 +258,27 @@ export const createStaticOAuthMcpServer = async ( }))); } +export const updateMcpServerScopes = async (serverId: string, scopes: string[]) => { + const parsed = updateMcpServerScopesSchema.safeParse({ serverId, scopes }); + if (!parsed.success) { + return requestBodySchemaValidationError(parsed.error); + } + + return sew(() => + withAuth(async ({ org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async (): Promise => { + if (!(await hasEntitlement('ask'))) { + return oauthNotSupported(); + } + + return updateMcpServerRequestedScopes({ + serverId: parsed.data.serverId, + orgId: org.id, + requestedScopes: parsed.data.scopes, + }); + }))); +}; + export const createMcpServer = async (name: string, serverUrl: string) => sew(() => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { diff --git a/packages/web/src/ee/features/chat/mcp/mcpScopes.ts b/packages/web/src/ee/features/chat/mcp/mcpScopes.ts new file mode 100644 index 000000000..a4951631d --- /dev/null +++ b/packages/web/src/ee/features/chat/mcp/mcpScopes.ts @@ -0,0 +1,93 @@ +import 'server-only'; + +import { ErrorCode } from '@/lib/errorCodes'; +import type { ServiceError } from '@/lib/serviceError'; +import { __unsafePrisma } from '@/prisma'; +import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; +import { StatusCodes } from 'http-status-codes'; + +export interface UpdateMcpServerScopesResponse { + success: true; + requestedScopes: string[]; + invalidatedConnectionCount: number; +} + +type McpServerScopePrismaClient = Pick; + +export function normalizeMcpRequestedScopes(scopes: string[]): string[] { + return [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))] + .sort(); +} + +function normalizedScopesEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((scope, index) => scope === b[index]); +} + +export async function updateMcpServerRequestedScopes({ + prisma = __unsafePrisma, + serverId, + orgId, + requestedScopes, +}: { + prisma?: McpServerScopePrismaClient; + serverId: string; + orgId: number; + requestedScopes: string[]; +}): Promise { + const normalizedRequestedScopes = normalizeMcpRequestedScopes(requestedScopes); + + return prisma.$transaction(async (tx) => { + const server = await tx.mcpServer.findFirst({ + where: { + id: serverId, + orgId, + }, + select: { + id: true, + requestedScopes: true, + clientInfoSource: true, + }, + }); + + if (!server) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.MCP_SERVER_NOT_FOUND, + message: 'Connector not found', + } satisfies ServiceError; + } + + const currentRequestedScopes = normalizeMcpRequestedScopes(server.requestedScopes); + if (normalizedScopesEqual(currentRequestedScopes, normalizedRequestedScopes)) { + return { + success: true, + requestedScopes: normalizedRequestedScopes, + invalidatedConnectionCount: 0, + }; + } + + await tx.mcpServer.update({ + where: { id: server.id }, + data: { + requestedScopes: normalizedRequestedScopes, + ...(server.clientInfoSource === McpServerClientInfoSource.DYNAMIC ? { clientInfo: null } : {}), + }, + }); + + const result = await tx.userMcpServer.updateMany({ + where: { serverId: server.id }, + data: { + tokens: null, + tokensExpiresAt: null, + codeVerifier: null, + state: null, + }, + }); + + return { + success: true, + requestedScopes: normalizedRequestedScopes, + invalidatedConnectionCount: result.count, + }; + }); +} From b9c0a041bd8da12fc013396ef3e0d612dc3befb4 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Sun, 31 May 2026 16:03:04 -0700 Subject: [PATCH 02/14] Add MCP OAuth scope discovery and selection --- .../workspaceAskAgentPage.tsx | 213 +++++++++++++++++- .../(server)/ee/askmcp/callback/route.test.ts | 4 + .../api/(server)/ee/askmcp/callback/route.ts | 2 + .../(server)/ee/askmcp/connect/route.test.ts | 2 + .../api/(server)/ee/askmcp/connect/route.ts | 2 + .../src/ee/features/chat/mcp/actions.test.ts | 40 ++++ .../web/src/ee/features/chat/mcp/actions.ts | 100 ++++---- .../ee/features/chat/mcp/dcrDiscovery.test.ts | 65 ++++++ .../src/ee/features/chat/mcp/dcrDiscovery.ts | 34 ++- .../chat/mcp/externalMcpError.test.ts | 14 ++ .../ee/features/chat/mcp/externalMcpError.ts | 1 + .../chat/mcp/mcpClientFactory.test.ts | 15 ++ .../ee/features/chat/mcp/mcpClientFactory.ts | 2 + .../web/src/ee/features/chat/mcp/mcpScopes.ts | 6 +- .../mcp/prismaOAuthClientProvider.test.ts | 26 ++- .../chat/mcp/prismaOAuthClientProvider.ts | 8 + .../src/ee/features/chat/mcp/scopeUtils.ts | 17 ++ 17 files changed, 494 insertions(+), 57 deletions(-) create mode 100644 packages/web/src/ee/features/chat/mcp/scopeUtils.ts diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 74096c44a..46ced80d9 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,11 +21,13 @@ 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 { Textarea } from "@/components/ui/textarea"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer } from "@/ee/features/chat/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton"; import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; +import { getMcpRequestedScopes, normalizeMcpRequestedScopes } from "@/ee/features/chat/mcp/scopeUtils"; import { pluralize } from "@/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -54,6 +57,98 @@ type WorkspaceConnectorStatus = { isAuthExpired: boolean; }; +type PendingConnectorServer = { + name: string; + serverUrl: string; + discoveredScopes: string[]; +}; + +interface OAuthScopesInputProps { + discoveredScopes: string[]; + selectedScopes: string[]; + customScopeInput: string; + customScopesInputId: string; + onSelectedScopesChange: (scopes: string[]) => void; + onCustomScopeInputChange: (value: string) => void; +} + +function OAuthScopesInput({ + discoveredScopes, + selectedScopes, + customScopeInput, + customScopesInputId, + onSelectedScopesChange, + onCustomScopeInputChange, +}: OAuthScopesInputProps) { + const selectedScopeSet = new Set(selectedScopes); + const requestedScopes = getMcpRequestedScopes(selectedScopes, customScopeInput); + + const handleCheckedChange = (scope: string, checked: boolean) => { + onSelectedScopesChange(checked + ? normalizeMcpRequestedScopes([...selectedScopes, scope]) + : selectedScopes.filter((selectedScope) => selectedScope !== scope)); + }; + + const handleSelectAll = () => { + onSelectedScopesChange(normalizeMcpRequestedScopes([...selectedScopes, ...discoveredScopes])); + }; + + const handleClear = () => { + onSelectedScopesChange([]); + onCustomScopeInputChange(""); + }; + + return ( +
+
+
+ +

{requestedScopes.length} requested

+
+
+ {discoveredScopes.length > 0 && ( + + )} + +
+
+ + {discoveredScopes.length > 0 && ( +
+ {discoveredScopes.map((scope) => ( + + ))} +
+ )} + +
+ +