+
+
+
+
+
+
+ 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 (
+
+ );
+ }
+
+ const known = matchKnownClient(name);
+ if (known) {
+ return (
+ <>
+
+ {known.logoSrcDark && (
+
+ )}
+ >
+ );
+ }
+
+ 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