diff --git a/packages/web/package.json b/packages/web/package.json index 8825a21c5..dfed8625f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -175,7 +175,7 @@ "react-dom": "19.2.4", "react-hook-form": "^7.53.0", "react-hotkeys-hook": "^4.5.1", - "react-icons": "^5.3.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.1", "recharts": "^2.15.3", diff --git a/packages/web/public/claude_code.svg b/packages/web/public/claude_code.svg new file mode 100644 index 000000000..853a243c6 --- /dev/null +++ b/packages/web/public/claude_code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/public/codex.svg b/packages/web/public/codex.svg new file mode 100644 index 000000000..c77ccfdd9 --- /dev/null +++ b/packages/web/public/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/packages/web/public/cursor_dark.svg b/packages/web/public/cursor_dark.svg new file mode 100644 index 000000000..10d50ca84 --- /dev/null +++ b/packages/web/public/cursor_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/web/public/cursor_light.svg b/packages/web/public/cursor_light.svg new file mode 100644 index 000000000..635d3ccdc --- /dev/null +++ b/packages/web/public/cursor_light.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/web/public/vscode.svg b/packages/web/public/vscode.svg new file mode 100644 index 000000000..0557c2cb3 --- /dev/null +++ b/packages/web/public/vscode.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web/public/windsurf_dark.svg b/packages/web/public/windsurf_dark.svg new file mode 100644 index 000000000..2e4e4e492 --- /dev/null +++ b/packages/web/public/windsurf_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/public/windsurf_light.svg b/packages/web/public/windsurf_light.svg new file mode 100644 index 000000000..386f8c035 --- /dev/null +++ b/packages/web/public/windsurf_light.svg @@ -0,0 +1,3 @@ + + + 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 1c2cd5857..8eb817e55 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -23,9 +23,11 @@ import { UserIcon, UsersIcon, } from "lucide-react"; +import { VscMcp } from "react-icons/vsc"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { UpgradeBadge } from "../upgradeBadge"; +import { IconType } from "react-icons/lib"; const iconMap = { "link": LinkIcon, @@ -37,7 +39,8 @@ const iconMap = { "scroll-text": ScrollTextIcon, "settings": Settings2Icon, "user": UserIcon, -} satisfies Record; + "mcp": VscMcp, +} satisfies Record; export type NavIconName = keyof typeof iconMap; diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 96f84eeea..a98d22942 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -82,6 +82,11 @@ export const getSidebarNavGroups = async () => icon: "link" as const, } ] : []), + { + title: "MCP Server", + href: `/settings/mcp`, + icon: 'mcp' as const, + } ], }, ]; diff --git a/packages/web/src/app/(app)/settings/mcp/clientCard.tsx b/packages/web/src/app/(app)/settings/mcp/clientCard.tsx new file mode 100644 index 000000000..72c03bfda --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/clientCard.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/hooks/use-toast"; +import { Check, Copy, ExternalLink } from "lucide-react"; +import Image from "next/image"; +import { useState } from "react"; +import { SettingsCard } from "../components/settingsCard"; +import { type McpClient, buildClientAction } from "./clients"; + +interface ClientCardProps { + client: McpClient; + serverUrl: string; +} + +export function ClientCard({ client, serverUrl }: ClientCardProps) { + const { toast } = useToast(); + const [commandCopied, setCommandCopied] = useState(false); + const action = buildClientAction(client.id, serverUrl); + + const handleCopyCommand = (command: string) => { + navigator.clipboard.writeText(command) + .then(() => { + setCommandCopied(true); + setTimeout(() => setCommandCopied(false), 2000); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to copy command", + variant: "destructive", + }); + }); + }; + + return ( + +
+
+
+ {`${client.name} + {client.logoSrcDark && ( + {`${client.name} + )} +
+ {client.name} +
+ {action.type === 'deeplink' && ( + + )} + {action.type === 'command' && ( + + )} + {action.type === 'docs' && ( + + )} +
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/mcp/clients.ts b/packages/web/src/app/(app)/settings/mcp/clients.ts new file mode 100644 index 000000000..15c8747b3 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/clients.ts @@ -0,0 +1,78 @@ +export type McpClientId = 'cursor' | 'vscode' | 'claude-code' | 'codex' | 'windsurf'; + +export interface McpClient { + id: McpClientId; + name: string; + logoSrc: string; + logoSrcDark?: string; +} + +export const MCP_CLIENTS: McpClient[] = [ + { id: 'vscode', name: 'VS Code', logoSrc: '/vscode.svg' }, + { id: 'cursor', name: 'Cursor', logoSrc: '/cursor_light.svg', logoSrcDark: '/cursor_dark.svg' }, + { id: 'claude-code', name: 'Claude Code', logoSrc: '/claude_code.svg' }, + { id: 'codex', name: 'Codex', logoSrc: '/codex.svg' }, + { id: 'windsurf', name: 'Windsurf', logoSrc: '/windsurf_light.svg', logoSrcDark: '/windsurf_dark.svg' }, +]; + +const NAME_ALIASES: Record = { + 'vscode': ['visual studio code', 'vscode', 'vs code'], + 'cursor': ['cursor'], + 'claude-code': ['claude code', 'claude-code'], + 'codex': ['codex'], + 'windsurf': ['windsurf'], +}; + +function normalize(s: string): string { + return s.toLowerCase().trim().replace(/\s+/g, ' '); +} + +export function matchKnownClient(name: string): McpClient | null { + const normalized = normalize(name); + for (const client of MCP_CLIENTS) { + const aliases = NAME_ALIASES[client.id].map(normalize); + if (aliases.some((alias) => normalized === alias || normalized.includes(alias))) { + return client; + } + } + return null; +} + +export type ClientAction = + | { type: 'deeplink'; href: string } + | { type: 'command'; command: string } + | { type: 'docs'; href: string }; + +export function buildClientAction(clientId: McpClientId, serverUrl: string): ClientAction { + switch (clientId) { + case 'cursor': { + const config = btoa(JSON.stringify({ url: serverUrl })); + return { + type: 'deeplink', + href: `cursor://anysphere.cursor-deeplink/mcp/install?name=sourcebot&config=${config}`, + }; + } + case 'vscode': { + const config = JSON.stringify({ name: 'sourcebot', url: serverUrl }); + return { + type: 'deeplink', + href: `vscode:mcp/install?${encodeURIComponent(config)}`, + }; + } + case 'claude-code': + return { + type: 'command', + command: `claude mcp add --transport http sourcebot ${serverUrl}`, + }; + case 'codex': + return { + type: 'command', + command: `codex mcp add sourcebot --url ${serverUrl}`, + }; + case 'windsurf': + return { + type: 'docs', + href: 'https://docs.windsurf.com/windsurf/cascade/mcp', + }; + } +} diff --git a/packages/web/src/app/(app)/settings/mcp/mcpPage.tsx b/packages/web/src/app/(app)/settings/mcp/mcpPage.tsx new file mode 100644 index 000000000..38eac2872 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/mcpPage.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/hooks/use-toast"; +import { type ConnectedOauthClient, revokeMcpClient } from "@/ee/features/oauth/actions"; +import { isServiceError } from "@/lib/utils"; +import { formatDistanceToNow } from "date-fns"; +import { Boxes, Trash2 } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { CopyIconButton } from "../../components/copyIconButton"; +import { SettingsCard, SettingsCardGroup } from "../components/settingsCard"; +import { ClientCard } from "./clientCard"; +import { MCP_CLIENTS, matchKnownClient } from "./clients"; + +const DOCS_URL = "https://docs.sourcebot.dev/docs/features/mcp-server"; + +interface McpPageProps { + mcpServerUrl: string; + connectedClients: ConnectedOauthClient[]; +} + +export function McpPage({ + mcpServerUrl, + connectedClients +}: McpPageProps) { + const { toast } = useToast(); + const router = useRouter(); + + const handleCopyServerUrl = () => { + navigator.clipboard.writeText(mcpServerUrl) + .catch(() => { + toast({ + title: "Error", + description: "Failed to copy URL to clipboard", + variant: "destructive", + }); + }); + return true; + }; + + const handleRevokeClient = async (clientId: string, name: string) => { + const result = await revokeMcpClient({ clientId }); + if (isServiceError(result)) { + toast({ + title: "Error", + description: `Failed to revoke ${name}: ${result.message}`, + variant: "destructive", + }); + return; + } + router.refresh(); + toast({ description: `${name} has been disconnected.` }); + }; + + return ( +
+
+

MCP Server

+

+ Connect AI coding tools to search and read your code through Sourcebot's MCP server. Learn more +

+
+ + +

Server URL

+
+
+ {mcpServerUrl} +
+ +
+
+ +
+
+

Install in a client

+

+ Set up Sourcebot in your editor or coding agent. +

+
+
+ {MCP_CLIENTS.map((client) => ( + + ))} +
+
+ +
+
+

Connected clients

+

+ MCP clients that have been authorized to access Sourcebot on your behalf. +

+
+ + {connectedClients.length === 0 ? ( + +
+ No clients connected yet. +
+
+ ) : ( + + {connectedClients.map((client) => ( + +
+
+ +
+
+ {client.name} + + Connected {formatDistanceToNow(client.connectedAt, { addSuffix: true })} + {" ยท "} + {client.lastUsedAt + ? `last used ${formatDistanceToNow(client.lastUsedAt, { addSuffix: true })}` + : "never used" + } + +
+ + + + + + + Disconnect {client.name}? + + {client.name} will no longer be able to access Sourcebot. You can reconnect it by re-authorizing from the client. This action cannot be undone. + + + + Cancel + handleRevokeClient(client.id, client.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Disconnect + + + + +
+
+ ))} +
+ )} +
+
+ ); +} + +function ConnectedClientLogo({ logoUri, name }: { logoUri: string | null; name: string }) { + if (logoUri) { + return ( + {`${name} + ); + } + + const known = matchKnownClient(name); + if (known) { + return ( + <> + {`${name} + {known.logoSrcDark && ( + {`${name} + )} + + ); + } + + return ; +} diff --git a/packages/web/src/app/(app)/settings/mcp/page.tsx b/packages/web/src/app/(app)/settings/mcp/page.tsx new file mode 100644 index 000000000..a24e7ba37 --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/page.tsx @@ -0,0 +1,28 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { getConnectedOauthClients } from "@/ee/features/oauth/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { env } from "@sourcebot/shared"; +import { McpPage } from "./mcpPage"; + +export default authenticatedPage(async () => { + const mcpServerUrl = `${env.AUTH_URL.replace(/\/$/, '')}/api/mcp`; + + /** + * @note at the time of writing (May 26, 26'), the only type of + * OAuth client we would expect are MCP clients. In a future where + * we support other kinds of OAuth clients (e.g., CLI), we will + * need to update our assumptions. + */ + const connectedClients = await getConnectedOauthClients(); + if (isServiceError(connectedClients)) { + throw new ServiceErrorException(connectedClients); + } + + return ( + + ) +}); diff --git a/packages/web/src/ee/features/oauth/actions.ts b/packages/web/src/ee/features/oauth/actions.ts index ee3ee9a42..5d9928d63 100644 --- a/packages/web/src/ee/features/oauth/actions.ts +++ b/packages/web/src/ee/features/oauth/actions.ts @@ -5,6 +5,14 @@ import { generateAndStoreAuthCode } from '@/ee/features/oauth/server'; import { withAuth } from '@/middleware/withAuth'; import { UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants'; +export interface ConnectedOauthClient { + id: string; + name: string; + logoUri: string | null; + connectedAt: Date; + lastUsedAt: Date | null; +} + /** * Resolves the final URL to navigate to after an authorization decision. * Non-web redirect URIs (e.g. custom schemes like vscode://) are wrapped in @@ -71,3 +79,55 @@ export const denyAuthorization = async ({ if (state) callbackUrl.searchParams.set('state', state); return resolveCallbackUrl(callbackUrl); })) + +/** + * Lists the OAuth clients that the current user has authorized. + * A client is considered "connected" if it has at least one refresh token for + * the user. Refresh tokens outlive short-lived access tokens, so they're the + * better signal of an active connection. + */ +export const getConnectedOauthClients = async () => sew(() => + withAuth(async ({ user, prisma }) => { + const clients = await prisma.oAuthClient.findMany({ + where: { refreshTokens: { some: { userId: user.id } } }, + select: { + id: true, + name: true, + logoUri: true, + refreshTokens: { + where: { userId: user.id }, + select: { createdAt: true }, + orderBy: { createdAt: 'asc' }, + take: 1, + }, + tokens: { + where: { userId: user.id }, + select: { lastUsedAt: true }, + orderBy: { lastUsedAt: 'desc' }, + take: 1, + }, + }, + }); + + return clients.map((client) => ({ + id: client.id, + name: client.name, + logoUri: client.logoUri, + connectedAt: client.refreshTokens[0].createdAt, + lastUsedAt: client.tokens[0]?.lastUsedAt ?? null, + })); + })); + +/** + * Revokes all tokens for the given OAuth client / current user pair, fully + * disconnecting that MCP client from the user's account. + */ +export const revokeMcpClient = async ({ clientId }: { clientId: string }) => sew(() => + withAuth(async ({ user, prisma }) => { + await prisma.$transaction([ + prisma.oAuthToken.deleteMany({ where: { clientId, userId: user.id } }), + prisma.oAuthRefreshToken.deleteMany({ where: { clientId, userId: user.id } }), + prisma.oAuthAuthorizationCode.deleteMany({ where: { clientId, userId: user.id } }), + ]); + return { success: true }; + })); diff --git a/yarn.lock b/yarn.lock index e4545b495..2357fe36c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9523,7 +9523,7 @@ __metadata: react-grab: "npm:^0.1.23" react-hook-form: "npm:^7.53.0" react-hotkeys-hook: "npm:^4.5.1" - react-icons: "npm:^5.3.0" + react-icons: "npm:^5.6.0" react-markdown: "npm:^10.1.0" react-resizable-panels: "npm:^2.1.1" react-scan: "npm:^0.5.3" @@ -19737,12 +19737,12 @@ __metadata: languageName: node linkType: hard -"react-icons@npm:^5.3.0": - version: 5.5.0 - resolution: "react-icons@npm:5.5.0" +"react-icons@npm:^5.6.0": + version: 5.6.0 + resolution: "react-icons@npm:5.6.0" peerDependencies: react: "*" - checksum: 10c0/a24309bfc993c19cbcbfc928157e53a137851822779977b9588f6dd41ffc4d11ebc98b447f4039b0d309a858f0a42980f6bfb4477fb19f9f2d1bc2e190fcf79c + checksum: 10c0/addba1ebfd45cdf7f69291d0c93df64fca1271cfdb66df657abd223e3451c73356b035f50e75858627dd182b02129596c79eaaaa45d32eb52658e443a992645c languageName: node linkType: hard