From 3bae8f246fb40abdb78af37b2204bad0ba135f0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:33:13 +0000 Subject: [PATCH 1/2] feat(site): add UI option to disconnect OAuth2 MCP credentials --- coderd/mcp_test.go | 42 +++++- site/src/api/api.ts | 6 + site/src/api/queries/chats.ts | 7 + .../components/AgentChatInput.stories.tsx | 120 +++++++++++++++++- .../AgentsPage/components/AgentChatInput.tsx | 71 +++++++++-- 5 files changed, 234 insertions(+), 12 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index c3e772120728f..73d1a0af790a8 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -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,39 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { // Disconnect should succeed even when no token exists (idempotent). err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) require.NoError(t, err) + + // Seed valid tokens for two users. + 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) + + // Disconnecting removes only the calling user's token. + err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) + require.NoError(t, err) + requireAuthConnected(memberClient, false) + requireAuthConnected(otherClient, true) + + // Repeat disconnect after the token is gone is still a no-op. + err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) + require.NoError(t, err) } func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b4efff76f0134..e89a8f1994a7a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3976,6 +3976,12 @@ class ExperimentalApiMethods { ); }; + disconnectMCPServerOAuth2 = async (id: string): Promise => { + await this.axios.delete( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}/oauth2/disconnect`, + ); + }; + getChatCostSummary = async ( user = "me", params?: ChatCostDateParams, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 8262c8f491876..281f04234b18a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 9f010a948eb98..f5447856d63c0 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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,113 @@ export const PlusMenuOpen: Story = { }, }; +/** Connected OAuth2 servers show a disconnect control; others do not. */ +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(); + }, +}; + +/** Cancelling the disconnect dialog makes no API call. */ +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(); + }, +}; + +/** Confirming the disconnect dialog calls the disconnect endpoint. */ +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, + ); + }, +}; + +/** A failed disconnect keeps the dialog open so the user can retry. */ +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(), diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 9867529733740..9c205b7f7126a 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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 = ({ const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); const [mcpConnectingId, setMcpConnectingId] = useState(null); const mcpPopupRef = useRef(null); + const [mcpDisconnectTarget, setMcpDisconnectTarget] = + useState(null); + const queryClient = useQueryClient(); + const mcpDisconnectMutation = useMutation( + disconnectMCPServerOAuth2(queryClient), + ); const [hasFileReferences, setHasFileReferences] = useState(false); const [cycleIndex, setCycleIndex] = useState(null); @@ -554,6 +566,20 @@ export const AgentChatInput: FC = ({ ); }; + const handleMcpDisconnectConfirm = async () => { + if (!mcpDisconnectTarget) { + return; + } + const name = mcpDisconnectTarget.display_name; + try { + await mcpDisconnectMutation.mutateAsync(mcpDisconnectTarget.id); + setMcpDisconnectTarget(null); + toast.success(`Disconnected ${name}.`); + } catch (error) { + toast.error(getErrorMessage(error, `Failed to disconnect ${name}.`)); + } + }; + const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, ); @@ -1400,15 +1426,32 @@ export const AgentChatInput: FC = ({ Auth ) : ( - - handleMcpToggle(server.id, checked) - } - disabled={isDisabled || isForceOn} - aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} - /> + <> + {server.auth_type === "oauth2" && ( + + )} + + handleMcpToggle(server.id, checked) + } + disabled={isDisabled || isForceOn} + aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} + /> + )} ); @@ -1648,6 +1691,16 @@ export const AgentChatInput: FC = ({ }} /> )} + void handleMcpDisconnectConfirm()} + onClose={() => setMcpDisconnectTarget(null)} + /> ); }; From 9232e5b03a9cbeb56be2f1658681b33c731e59d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:50:15 +0000 Subject: [PATCH 2/2] refactor(site): simplify MCP disconnect confirm handler and trim comments --- coderd/mcp_test.go | 3 --- .../components/AgentChatInput.stories.tsx | 4 ---- .../AgentsPage/components/AgentChatInput.tsx | 20 ++++++++++--------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 73d1a0af790a8..5ef5709f70074 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -555,7 +555,6 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) require.NoError(t, err) - // Seed valid tokens for two users. 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{ @@ -578,13 +577,11 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { requireAuthConnected(memberClient, true) requireAuthConnected(otherClient, true) - // Disconnecting removes only the calling user's token. err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) require.NoError(t, err) requireAuthConnected(memberClient, false) requireAuthConnected(otherClient, true) - // Repeat disconnect after the token is gone is still a no-op. err = memberClient.MCPServerOAuth2Disconnect(ctx, created.ID) require.NoError(t, err) } diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index f5447856d63c0..ed58e95e641f4 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -811,7 +811,6 @@ export const PlusMenuOpen: Story = { }, }; -/** Connected OAuth2 servers show a disconnect control; others do not. */ export const MCPDisconnectControls: Story = { args: { ...mcpDefaults, @@ -835,7 +834,6 @@ export const MCPDisconnectControls: Story = { }, }; -/** Cancelling the disconnect dialog makes no API call. */ export const MCPDisconnectCancel: Story = { args: { ...mcpDefaults, @@ -861,7 +859,6 @@ export const MCPDisconnectCancel: Story = { }, }; -/** Confirming the disconnect dialog calls the disconnect endpoint. */ export const MCPDisconnectConfirm: Story = { args: { ...mcpDefaults, @@ -890,7 +887,6 @@ export const MCPDisconnectConfirm: Story = { }, }; -/** A failed disconnect keeps the dialog open so the user can retry. */ export const MCPDisconnectError: Story = { args: { ...mcpDefaults, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 9c205b7f7126a..335434dacfbdc 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -566,18 +566,20 @@ export const AgentChatInput: FC = ({ ); }; - const handleMcpDisconnectConfirm = async () => { + const handleMcpDisconnectConfirm = () => { if (!mcpDisconnectTarget) { return; } const name = mcpDisconnectTarget.display_name; - try { - await mcpDisconnectMutation.mutateAsync(mcpDisconnectTarget.id); - setMcpDisconnectTarget(null); - toast.success(`Disconnected ${name}.`); - } catch (error) { - toast.error(getErrorMessage(error, `Failed to disconnect ${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( @@ -1698,7 +1700,7 @@ export const AgentChatInput: FC = ({ type="delete" confirmText="Disconnect" confirmLoading={mcpDisconnectMutation.isPending} - onConfirm={() => void handleMcpDisconnectConfirm()} + onConfirm={handleMcpDisconnectConfirm} onClose={() => setMcpDisconnectTarget(null)} />