feat: add UI option to disconnect OAuth2 MCP credentials (#27299)

Closes
[CODAGT-804](https://linear.app/codercom/issue/CODAGT-804/add-ui-option-to-revoke-oauth-mcp-credentials).

Users could authenticate with an OAuth2 MCP server from the chat input,
but there was no UI to disconnect those per-user credentials. The
backend endpoint (`DELETE
/api/experimental/mcp/servers/{id}/oauth2/disconnect`) already existed.

## Changes

- Connected OAuth2 MCP rows in the chat input plus menu now show a
disconnect icon button next to the enable switch. It opens a
confirmation dialog; confirming calls the disconnect endpoint, shows a
toast, and refetches MCP configs so the row reverts to the `Auth` button
without a reload.
- New `disconnectMCPServerOAuth2` API client method and react-query
mutation that invalidates `mcp-server-configs`.
- Storybook interaction tests: control visibility per auth state, cancel
makes no API call, confirm calls the endpoint once, failed disconnect
keeps the dialog open.
- Hardened `TestMCPServerConfigsOAuth2Disconnect`: seeded tokens flip
`auth_connected`, disconnect only removes the calling user's token, and
repeat disconnect stays idempotent.

The endpoint removes the token stored in Coder; it does not revoke the
upstream OAuth grant, so the UI copy says "disconnect" rather than
"revoke".

Validated with the targeted Go test, Storybook tests (51 passed), tsc,
biome, the react-compiler check, and a manual dogfood run (seeded token,
disconnect/cancel/reconnect flows verified in the UI).

> This PR was authored by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-07-16 18:26:35 +02:00
committed by GitHub
parent 101aee8ee0
commit 3dd9265fa6
5 changed files with 229 additions and 12 deletions
+37 -2
View File
@@ -526,9 +526,14 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
otherClient, other := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Disconnect Test",
@@ -549,6 +554,36 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) {
// Disconnect should succeed even when no token exists (idempotent).
err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID)
require.NoError(t, err)
for _, userID := range []uuid.UUID{member.ID, other.ID} {
//nolint:gocritic // Seeding test state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: userID,
AccessToken: "valid-access",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
}
requireAuthConnected := func(client *codersdk.Client, want bool) {
t.Helper()
configs, err := client.MCPServerConfigs(ctx)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, want, configs[0].AuthConnected)
}
requireAuthConnected(memberClient, true)
requireAuthConnected(otherClient, true)
err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID)
require.NoError(t, err)
requireAuthConnected(memberClient, false)
requireAuthConnected(otherClient, true)
err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID)
require.NoError(t, err)
}
func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) {
+6
View File
@@ -3976,6 +3976,12 @@ class ExperimentalApiMethods {
);
};
disconnectMCPServerOAuth2 = async (id: string): Promise<void> => {
await this.axios.delete(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}/oauth2/disconnect`,
);
};
getChatCostSummary = async (
user = "me",
params?: ChatCostDateParams,
+7
View File
@@ -2070,6 +2070,13 @@ export const deleteMCPServerConfig = (queryClient: QueryClient) => ({
},
});
export const disconnectMCPServerOAuth2 = (queryClient: QueryClient) => ({
mutationFn: (id: string) => API.experimental.disconnectMCPServerOAuth2(id),
onSuccess: async () => {
await invalidateMCPServerConfigQueries(queryClient);
},
});
type SetChatUserRoleVariables = {
chatId: string;
userId: string;
@@ -1,7 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MonitorDotIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockChatContextClean,
@@ -739,6 +740,16 @@ const githubMCP = buildMCPServer({
const githubMCPConnected = { ...githubMCP, auth_connected: true };
const notionMCPConnected = buildMCPServer({
id: "mcp-notion",
display_name: "Notion",
slug: "notion",
availability: "default_on",
auth_type: "oauth2",
auth_connected: true,
enabled: true,
});
const mcpDefaults = {
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
@@ -800,6 +811,109 @@ export const PlusMenuOpen: Story = {
},
};
export const MCPDisconnectControls: Story = {
args: {
...mcpDefaults,
mcpServers: [linearMCP, githubMCP, notionMCPConnected],
selectedMCPServerIds: [linearMCP.id, notionMCPConnected.id],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
expect(
await body.findByRole("button", { name: "Disconnect Notion" }),
).toBeInTheDocument();
expect(
body.queryByRole("button", { name: "Disconnect GitHub" }),
).not.toBeInTheDocument();
expect(body.getByRole("button", { name: "Auth" })).toBeInTheDocument();
expect(
body.queryByRole("button", { name: "Disconnect Linear" }),
).not.toBeInTheDocument();
},
};
export const MCPDisconnectCancel: Story = {
args: {
...mcpDefaults,
mcpServers: [githubMCPConnected],
selectedMCPServerIds: [githubMCPConnected.id],
},
beforeEach: () => {
spyOn(API.experimental, "disconnectMCPServerOAuth2").mockResolvedValue();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await userEvent.click(
await body.findByRole("button", { name: "Disconnect GitHub" }),
);
expect(await body.findByText("Disconnect GitHub?")).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(body.queryByText("Disconnect GitHub?")).not.toBeInTheDocument(),
);
expect(API.experimental.disconnectMCPServerOAuth2).not.toHaveBeenCalled();
},
};
export const MCPDisconnectConfirm: Story = {
args: {
...mcpDefaults,
mcpServers: [githubMCPConnected],
selectedMCPServerIds: [githubMCPConnected.id],
},
beforeEach: () => {
spyOn(API.experimental, "disconnectMCPServerOAuth2").mockResolvedValue();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await userEvent.click(
await body.findByRole("button", { name: "Disconnect GitHub" }),
);
await body.findByText("Disconnect GitHub?");
await userEvent.click(body.getByRole("button", { name: "Disconnect" }));
await waitFor(() =>
expect(body.queryByText("Disconnect GitHub?")).not.toBeInTheDocument(),
);
expect(API.experimental.disconnectMCPServerOAuth2).toHaveBeenCalledTimes(1);
expect(API.experimental.disconnectMCPServerOAuth2).toHaveBeenCalledWith(
githubMCPConnected.id,
);
},
};
export const MCPDisconnectError: Story = {
args: {
...mcpDefaults,
mcpServers: [githubMCPConnected],
selectedMCPServerIds: [githubMCPConnected.id],
},
beforeEach: () => {
spyOn(API.experimental, "disconnectMCPServerOAuth2").mockRejectedValue(
new Error("disconnect failed"),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await userEvent.click(
await body.findByRole("button", { name: "Disconnect GitHub" }),
);
await body.findByText("Disconnect GitHub?");
await userEvent.click(body.getByRole("button", { name: "Disconnect" }));
await waitFor(() =>
expect(API.experimental.disconnectMCPServerOAuth2).toHaveBeenCalled(),
);
expect(body.getByText("Disconnect GitHub?")).toBeInTheDocument();
},
};
export const PlanFirstMenuItem: Story = {
args: {
onPlanModeToggle: fn(),
@@ -10,6 +10,7 @@ import {
PlusIcon,
ServerIcon,
SquareIcon,
UnlinkIcon,
XIcon,
} from "lucide-react";
import type React from "react";
@@ -20,7 +21,11 @@ import {
useRef,
useState,
} from "react";
import { useMutation, useQueryClient } from "react-query";
import { Link } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { disconnectMCPServerOAuth2 } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type {
AgentChatSendShortcut,
@@ -37,6 +42,7 @@ import {
CommandItem,
CommandList,
} from "#/components/Command/Command";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
Popover,
@@ -428,6 +434,12 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false);
const [mcpConnectingId, setMcpConnectingId] = useState<string | null>(null);
const mcpPopupRef = useRef<Window | null>(null);
const [mcpDisconnectTarget, setMcpDisconnectTarget] =
useState<TypesGen.MCPServerConfig | null>(null);
const queryClient = useQueryClient();
const mcpDisconnectMutation = useMutation(
disconnectMCPServerOAuth2(queryClient),
);
const [hasFileReferences, setHasFileReferences] = useState(false);
const [cycleIndex, setCycleIndex] = useState<number | null>(null);
@@ -554,6 +566,22 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
);
};
const handleMcpDisconnectConfirm = () => {
if (!mcpDisconnectTarget) {
return;
}
const name = mcpDisconnectTarget.display_name;
mcpDisconnectMutation.mutate(mcpDisconnectTarget.id, {
onSuccess: () => {
setMcpDisconnectTarget(null);
toast.success(`Disconnected ${name}.`);
},
onError: (error) => {
toast.error(getErrorMessage(error, `Failed to disconnect ${name}.`));
},
});
};
const selectedWorkspace = workspaceOptions?.find(
(ws) => ws.id === selectedWorkspaceId,
);
@@ -1400,15 +1428,32 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
Auth
</Button>
) : (
<Switch
size="sm"
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
<>
{server.auth_type === "oauth2" && (
<Button
variant="subtle"
size="icon"
className="size-6 shrink-0 text-content-secondary [&>svg]:size-3"
onClick={() => {
setPlusMenuOpen(false);
setMcpDisconnectTarget(server);
}}
disabled={isDisabled}
aria-label={`Disconnect ${server.display_name}`}
>
<UnlinkIcon />
</Button>
)}
<Switch
size="sm"
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
</>
)}
</div>
);
@@ -1648,6 +1693,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
}}
/>
)}
<ConfirmDialog
open={mcpDisconnectTarget !== null}
title={`Disconnect ${mcpDisconnectTarget?.display_name ?? "MCP server"}?`}
description="This removes your credentials for this MCP server from Coder. You can authenticate again later."
type="delete"
confirmText="Disconnect"
confirmLoading={mcpDisconnectMutation.isPending}
onConfirm={handleMcpDisconnectConfirm}
onClose={() => setMcpDisconnectTarget(null)}
/>
</>
);
};