{
]);
});
- it("does not offer removal of a force-on MCP server", async () => {
- const user = userEvent.setup();
- renderInput(
- ,
- );
-
- await user.click(screen.getByRole("button", { name: "3 MCP servers" }));
- expect(
- within(screen.getByRole("dialog")).queryByRole("button", {
- name: "Remove Sentry",
- }),
- ).toBeNull();
- });
-
it("removes a single MCP server directly from the toolbar", async () => {
const user = userEvent.setup();
const onMCPSelectionChange = vi.fn();
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
index b8b330e77db39..44bb63fda23d5 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
@@ -62,6 +62,7 @@ import {
type ModelSelectorOption,
} from "#/modules/aiModels/ModelSelector";
import { useDashboard } from "#/modules/dashboard/useDashboard";
+import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
@@ -91,7 +92,7 @@ import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu";
import type { AgentContextUsage } from "./ContextUsageIndicator";
import { ContextUsageIndicator } from "./ContextUsageIndicator";
import { ImageLightbox } from "./ImageLightbox";
-import { MCPServerIcon, MCPServerIconStack } from "./MCPServerIconStack";
+import { MCPServerIconStack } from "./MCPServerIconStack";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
import { WorkspacePill } from "./WorkspacePill";
@@ -363,7 +364,11 @@ const ToolBadge: FC<{
key={server.id}
className="flex items-center gap-1.5 px-1 py-1.5 text-xs"
>
-
+
{server.display_name}
{server.availability === "force_on" ? (
diff --git a/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx b/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx
index ff176dd0cc710..b344acc148baf 100644
--- a/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx
+++ b/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx
@@ -1,32 +1,7 @@
import { cn } from "cn";
-import { ServerIcon } from "lucide-react";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
-import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
-
-// Decorative: every consumer renders the server name as text or labels
-// the containing control, so the icon itself carries no alt text.
-export const MCPServerIcon: FC<{
- iconUrl: string;
- className?: string;
-}> = ({ iconUrl, className }) => {
- const icon = iconUrl ? (
-
- ) : (
-
- );
-
- return (
-
- {icon}
-
- );
-};
+import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
const ICON_STACK_MAX = 3;
@@ -44,7 +19,11 @@ export const MCPServerIconStack: FC<{
i > 0 && "-ml-1.5",
)}
>
-
+
))}
{servers.length > ICON_STACK_MAX && (
diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx
index afe6552ac4157..b631310497a78 100644
--- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx
+++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx
@@ -16,7 +16,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
-import { MCPServerIcon, MCPServerIconStack } from "./MCPServerIconStack";
+import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
+import { MCPServerIconStack } from "./MCPServerIconStack";
// ── Types ──────────────────────────────────────────────────────
@@ -267,6 +268,7 @@ export const MCPServerPicker: FC
= ({
From 40ae39ef964c4016f26c6eaa80b46d64e1e6ba5b Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Sat, 12 Sep 2026 00:05:09 +0000
Subject: [PATCH 4/8] fix(site/src/pages/AgentsPage): announce always-on
servers in the MCP group
Screen readers only heard the server name for locked rows, so add
visually hidden "Always on" text next to the lock. Drop the remaining
DOM-absence assertions from the composer tests; the interaction that
follows already proves the single-server path.
---
site/src/pages/AgentsPage/components/AgentChatInput.test.tsx | 2 --
site/src/pages/AgentsPage/components/AgentChatInput.tsx | 5 ++++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
index 258b848266f1d..eb8652357f93b 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
@@ -143,7 +143,6 @@ describe("AgentChatInput", () => {
/>,
);
- expect(screen.queryByRole("button", { name: /MCP servers/ })).toBeNull();
await user.click(screen.getByRole("button", { name: "Remove Linear" }));
expect(onMCPSelectionChange).toHaveBeenCalledWith([]);
});
@@ -160,7 +159,6 @@ describe("AgentChatInput", () => {
/>,
);
- expect(screen.queryByRole("button", { name: /MCP servers/ })).toBeNull();
await user.click(screen.getByRole("button", { name: "Remove Linear" }));
expect(onMCPSelectionChange).toHaveBeenCalledWith([
mockGitHubMCPNeedingAuth.id,
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
index 44bb63fda23d5..6cbbf27c771a2 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
@@ -371,7 +371,10 @@ const ToolBadge: FC<{
/>
{server.display_name}
{server.availability === "force_on" ? (
-
+ <>
+
+ Always on
+ >
) : (
onRemoveMcp && (
Date: Mon, 14 Sep 2026 09:19:17 +0000
Subject: [PATCH 5/8] feat(site/src/pages/AgentsPage): manage MCP servers from
grouped pill
---
.../components/AgentChatInput.stories.tsx | 18 +-
.../components/AgentChatInput.test.tsx | 71 ++++++-
.../AgentsPage/components/AgentChatInput.tsx | 191 +++++++-----------
.../components/MCPServerToggleList.tsx | 89 ++++++++
4 files changed, 234 insertions(+), 135 deletions(-)
create mode 100644 site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
index a213ed5261dbf..c1d4e1c263fd5 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
@@ -687,7 +687,7 @@ const startMCPOAuthFlow = async (canvasElement: HTMLElement) => {
// ── MCP stories ────────────────────────────────────────────────
-/** Multiple selected servers share one grouped pill with an icon stack. */
+/** Three selected servers collapse into a single "3 MCPs" pill. */
export const WithMCPServers: Story = {
args: {
...mcpDefaults,
@@ -696,11 +696,19 @@ export const WithMCPServers: Story = {
},
};
+export const WithTwoMCPServers: Story = {
+ args: {
+ ...mcpDefaults,
+ mcpServers: [linearMCP, githubMCPConnected],
+ selectedMCPServerIds: [linearMCP.id, githubMCPConnected.id],
+ },
+};
+
export const MCPGroupPopoverOpen: Story = {
args: WithMCPServers.args,
play: async ({ canvasElement }) => {
await userEvent.click(
- within(canvasElement).getByRole("button", { name: "3 MCP servers" }),
+ within(canvasElement).getByRole("button", { name: "3 MCPs" }),
);
await within(document.body).findByRole("dialog");
},
@@ -1339,9 +1347,11 @@ export const MCPGroupInOverflow: Story = {
);
const overflow = await within(document.body).findByRole("dialog");
await userEvent.click(
- within(overflow).getByRole("button", { name: "3 MCP servers" }),
+ within(overflow).getByRole("button", { name: "3 MCPs" }),
);
- await within(document.body).findByRole("button", { name: "Remove Linear" });
+ await within(document.body).findByRole("switch", {
+ name: "Disable Linear",
+ });
},
};
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
index eb8652357f93b..6189e2ac0662a 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
@@ -63,6 +63,14 @@ const mockGitHubMCPNeedingAuth: TypesGen.MCPServerConfig = {
auth_connected: false,
};
+const mockNotionMCP: TypesGen.MCPServerConfig = {
+ ...MockMCPServerConfig,
+ id: "mcp-notion",
+ display_name: "Notion",
+ availability: "default_on",
+ auth_type: "api_key",
+};
+
const mockMCPServers = [mockSentryMCP, mockLinearMCP, mockGitHubMCP];
const mockSelectedMCPServerIds = mockMCPServers.map((server) => server.id);
@@ -119,10 +127,10 @@ describe("AgentChatInput", () => {
/>,
);
- await user.click(screen.getByRole("button", { name: "3 MCP servers" }));
+ await user.click(screen.getByRole("button", { name: "3 MCPs" }));
await user.click(
- within(screen.getByRole("dialog")).getByRole("button", {
- name: "Remove Linear",
+ within(screen.getByRole("dialog")).getByRole("switch", {
+ name: "Disable Linear",
}),
);
expect(onMCPSelectionChange).toHaveBeenCalledWith([
@@ -131,6 +139,48 @@ describe("AgentChatInput", () => {
]);
});
+ it("enables an unselected MCP server from the group popover", async () => {
+ const user = userEvent.setup();
+ const onMCPSelectionChange = vi.fn();
+ renderInput(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "3 MCPs" }));
+ await user.click(
+ within(screen.getByRole("dialog")).getByRole("switch", {
+ name: "Enable Notion",
+ }),
+ );
+ expect(onMCPSelectionChange).toHaveBeenCalledWith([
+ mockSentryMCP.id,
+ mockLinearMCP.id,
+ mockGitHubMCP.id,
+ mockNotionMCP.id,
+ ]);
+ });
+
+ it("keeps two active MCP servers as individual pills", async () => {
+ const user = userEvent.setup();
+ const onMCPSelectionChange = vi.fn();
+ renderInput(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Remove Linear" }));
+ expect(onMCPSelectionChange).toHaveBeenCalledWith([mockGitHubMCP.id]);
+ });
+
it("removes a single MCP server directly from the toolbar", async () => {
const user = userEvent.setup();
const onMCPSelectionChange = vi.fn();
@@ -153,14 +203,19 @@ describe("AgentChatInput", () => {
renderInput(
,
);
await user.click(screen.getByRole("button", { name: "Remove Linear" }));
expect(onMCPSelectionChange).toHaveBeenCalledWith([
+ mockSentryMCP.id,
mockGitHubMCPNeedingAuth.id,
]);
});
@@ -178,10 +233,10 @@ describe("AgentChatInput", () => {
/>,
);
- await user.click(screen.getByRole("button", { name: "3 MCP servers" }));
+ await user.click(screen.getByRole("button", { name: "3 MCPs" }));
await user.click(
- within(screen.getByRole("dialog")).getByRole("button", {
- name: "Remove Linear",
+ within(screen.getByRole("dialog")).getByRole("switch", {
+ name: "Disable Linear",
}),
);
expect(onMCPSelectionChange).not.toHaveBeenCalled();
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
index 6cbbf27c771a2..b11444624324a 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
@@ -3,8 +3,8 @@ import {
ArrowLeftIcon,
ArrowUpIcon,
CheckIcon,
+ ChevronDownIcon,
ChevronRightIcon,
- LockIcon,
MicIcon,
MonitorIcon,
PaperclipIcon,
@@ -12,11 +12,11 @@ import {
PlusIcon,
ServerIcon,
SquareIcon,
- UnlinkIcon,
XIcon,
} from "lucide-react";
import type React from "react";
import {
+ type ComponentProps,
type FC,
useEffect,
useImperativeHandle,
@@ -51,7 +51,6 @@ import {
import { Separator } from "#/components/Separator/Separator";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { Spinner } from "#/components/Spinner/Spinner";
-import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
@@ -62,7 +61,6 @@ import {
type ModelSelectorOption,
} from "#/modules/aiModels/ModelSelector";
import { useDashboard } from "#/modules/dashboard/useDashboard";
-import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
@@ -93,6 +91,7 @@ import type { AgentContextUsage } from "./ContextUsageIndicator";
import { ContextUsageIndicator } from "./ContextUsageIndicator";
import { ImageLightbox } from "./ImageLightbox";
import { MCPServerIconStack } from "./MCPServerIconStack";
+import { MCPServerToggleList } from "./MCPServerToggleList";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
import { WorkspacePill } from "./WorkspacePill";
@@ -250,8 +249,48 @@ const BadgeDismissButton: FC<{
);
+const MCPGroupBadge: FC<{
+ servers: readonly TypesGen.MCPServerConfig[];
+ mcpServerList: ComponentProps;
+ className?: string;
+}> = ({ servers, mcpServerList, className }) => {
+ const [open, setOpen] = useState(false);
+ const label = `${servers.length} MCPs`;
+
+ return (
+
+
+
+
+
+ {
+ setOpen(false);
+ mcpServerList.onDisconnect(server);
+ }}
+ />
+
+
+ );
+};
+
const ToolBadge: FC<{
badge: ToolBadgeData;
+ mcpServerList: ComponentProps;
onRemoveWorkspace?: () => void;
onRemoveMcp?: (serverId: string) => void;
onRemovePlanning?: () => void;
@@ -261,6 +300,7 @@ const ToolBadge: FC<{
disableTooltip?: boolean;
}> = ({
badge,
+ mcpServerList,
onRemoveWorkspace,
onRemoveMcp,
onRemovePlanning,
@@ -342,52 +382,12 @@ const ToolBadge: FC<{
}
if (badge.kind === "mcp-group") {
- const label = `${badge.servers.length} MCP servers`;
return (
-
-
-
-
-
- {badge.servers.map((server) => (
-
-
- {server.display_name}
- {server.availability === "force_on" ? (
- <>
-
- Always on
- >
- ) : (
- onRemoveMcp && (
- onRemoveMcp(server.id)}
- isDisabled={isDisabled}
- />
- )
- )}
-
- ))}
-
-
+
);
}
@@ -652,6 +652,15 @@ export const AgentChatInput: FC = ({
: false;
const enabledMcpServers = mcpServers?.filter((s) => s.enabled) ?? [];
+ const mcpServerListProps: ComponentProps = {
+ servers: enabledMcpServers,
+ selectedServerIds: selectedMCPServerIds,
+ onToggle: handleMcpToggle,
+ onConnect: connectMCPServer,
+ connectingServerId: mcpConnectingId,
+ onDisconnect: setMcpDisconnectTarget,
+ isDisabled,
+ };
const activeMcpServers = enabledMcpServers.filter(
(s) =>
(s.availability === "force_on" || selectedMCPServerIds?.includes(s.id)) &&
@@ -685,7 +694,7 @@ export const AgentChatInput: FC = ({
if (shouldShowSelectedWorkspaceBadge && selectedWorkspace) {
allBadges.push({ kind: "workspace", name: selectedWorkspace.name });
}
- if (activeMcpServers.length > 1) {
+ if (activeMcpServers.length > 2) {
allBadges.push({ kind: "mcp-group", servers: activeMcpServers });
} else {
for (const server of activeMcpServers) {
@@ -1424,79 +1433,13 @@ export const AgentChatInput: FC = ({
{enabledMcpServers.length > 0 && (
<>
- {enabledMcpServers.map((server) => {
- const isForceOn = server.availability === "force_on";
- const isSelected =
- isForceOn ||
- (selectedMCPServerIds?.includes(server.id) ??
- false);
- const needsAuth =
- server.auth_type === "oauth2" &&
- !server.auth_connected;
- const isConnecting = mcpConnectingId === server.id;
- return (
-
- {server.icon_url ? (
-
- ) : (
-
- )}
-
- {server.display_name}
-
- {needsAuth ? (
-
- ) : (
- <>
- {server.auth_type === "oauth2" && (
-
- )}
-
- handleMcpToggle(server.id, checked)
- }
- disabled={isDisabled || isForceOn}
- aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
- />
- >
- )}
-
- );
- })}
+ {
+ setPlusMenuOpen(false);
+ mcpServerListProps.onDisconnect(server);
+ }}
+ />
>
)}
>
@@ -1577,6 +1520,7 @@ export const AgentChatInput: FC = ({
badge={badge}
onRemoveWorkspace={removeWorkspaceHandler}
onRemoveMcp={handleRemoveMcp}
+ mcpServerList={mcpServerListProps}
onRemovePlanning={
onPlanModeToggle ? handleDisablePlanMode : undefined
}
@@ -1662,6 +1606,7 @@ export const AgentChatInput: FC = ({
badge={badge}
onRemoveWorkspace={removeWorkspaceHandler}
onRemoveMcp={handleRemoveMcp}
+ mcpServerList={mcpServerListProps}
onRemovePlanning={
onPlanModeToggle ? handleDisablePlanMode : undefined
}
diff --git a/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
new file mode 100644
index 0000000000000..a18a11f8161d9
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
@@ -0,0 +1,89 @@
+import { ServerIcon, UnlinkIcon } from "lucide-react";
+import type { FC } from "react";
+import type * as TypesGen from "#/api/typesGenerated";
+import { Button } from "#/components/Button/Button";
+import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
+import { Spinner } from "#/components/Spinner/Spinner";
+import { Switch } from "#/components/Switch/Switch";
+
+interface MCPServerToggleListProps {
+ servers: readonly TypesGen.MCPServerConfig[];
+ selectedServerIds: readonly string[] | undefined;
+ onToggle: (serverId: string, checked: boolean) => void;
+ onConnect: (serverId: string) => void;
+ connectingServerId: string | null;
+ onDisconnect: (server: TypesGen.MCPServerConfig) => void;
+ isDisabled?: boolean;
+}
+
+export const MCPServerToggleList: FC = ({
+ servers,
+ selectedServerIds,
+ onToggle,
+ onConnect,
+ connectingServerId,
+ onDisconnect,
+ isDisabled,
+}) => (
+ <>
+ {servers.map((server) => {
+ const isForceOn = server.availability === "force_on";
+ const isSelected =
+ isForceOn || (selectedServerIds?.includes(server.id) ?? false);
+ const needsAuth = server.auth_type === "oauth2" && !server.auth_connected;
+ const isConnecting = connectingServerId === server.id;
+ return (
+
+ {server.icon_url ? (
+
+ ) : (
+
+ )}
+
+ {server.display_name}
+
+ {needsAuth ? (
+
+ ) : (
+ <>
+ {server.auth_type === "oauth2" && (
+
+ )}
+ onToggle(server.id, checked)}
+ disabled={isDisabled || isForceOn}
+ aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
+ />
+ >
+ )}
+
+ );
+ })}
+ >
+);
From 1da767d0b849dc0875a25e04f8e99a9d18969890 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 14 Sep 2026 11:04:45 +0000
Subject: [PATCH 6/8] fix(site/src/pages/AgentsPage): mark always-on MCP rows
and cover pill disconnect
Force-on rows in the shared MCP server list show a lock and label their
disabled switch " always on" instead of "Disable ". Add a
Vitest test for disconnecting a connected OAuth server from the grouped
pill popover and a story for the resulting confirm dialog.
---
.../components/AgentChatInput.stories.tsx | 14 ++++++++++
.../components/AgentChatInput.test.tsx | 27 +++++++++++++++++++
.../components/MCPServerToggleList.tsx | 11 ++++++--
3 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
index c1d4e1c263fd5..ffe55c6ebb2da 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
@@ -722,6 +722,20 @@ export const MCPGroupDisabled: Story = {
play: MCPGroupPopoverOpen.play,
};
+export const MCPGroupDisconnectDialog: Story = {
+ args: WithMCPServers.args,
+ play: async ({ canvasElement }) => {
+ const body = within(canvasElement.ownerDocument.body);
+ await userEvent.click(
+ within(canvasElement).getByRole("button", { name: "3 MCPs" }),
+ );
+ await userEvent.click(
+ await body.findByRole("button", { name: "Disconnect GitHub" }),
+ );
+ await body.findByText("Disconnect GitHub?");
+ },
+};
+
/** MCP server needing OAuth — shows Auth button instead of toggle. */
export const WithMCPNeedingAuth: Story = {
args: {
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
index 6189e2ac0662a..dad6a321ca70e 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { type ComponentProps, createRef, type ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { AppProviders } from "#/App";
+import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
@@ -165,6 +166,32 @@ describe("AgentChatInput", () => {
]);
});
+ it("disconnects a connected OAuth server from the group popover", async () => {
+ const user = userEvent.setup();
+ const disconnect = vi
+ .spyOn(API.experimental, "disconnectMCPServerOAuth2")
+ .mockResolvedValue({ token_revoked: true });
+ renderInput(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "3 MCPs" }));
+ await user.click(
+ within(screen.getByRole("dialog")).getByRole("button", {
+ name: "Disconnect GitHub",
+ }),
+ );
+ await user.click(await screen.findByRole("button", { name: "Disconnect" }));
+ await waitFor(() => {
+ expect(disconnect).toHaveBeenCalledWith(mockGitHubMCP.id);
+ });
+ });
+
it("keeps two active MCP servers as individual pills", async () => {
const user = userEvent.setup();
const onMCPSelectionChange = vi.fn();
diff --git a/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
index a18a11f8161d9..709db78702eb9 100644
--- a/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
+++ b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
@@ -1,4 +1,4 @@
-import { ServerIcon, UnlinkIcon } from "lucide-react";
+import { LockIcon, ServerIcon, UnlinkIcon } from "lucide-react";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
@@ -73,12 +73,19 @@ export const MCPServerToggleList: FC = ({
)}
+ {isForceOn && (
+
+ )}
onToggle(server.id, checked)}
disabled={isDisabled || isForceOn}
- aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
+ aria-label={
+ isForceOn
+ ? `${server.display_name} always on`
+ : `${isSelected ? "Disable" : "Enable"} ${server.display_name}`
+ }
/>
>
)}
From fb19f14819f3bb4f34d6df4c8badffe483dc448b Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 14 Sep 2026 11:16:47 +0000
Subject: [PATCH 7/8] fix(site/src/pages/AgentsPage): keep the always-on lock
on MCP rows awaiting auth
An always-on OAuth server that has not connected yet showed only the Auth
button, hiding that it cannot be deselected once authenticated. Render the
lock ahead of the auth branch and add hidden "Always on" text where no
switch carries the label.
---
.../components/AgentChatInput.stories.tsx | 23 +++++++++++++
.../components/MCPServerToggleList.tsx | 33 ++++++++++---------
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
index ffe55c6ebb2da..5917a90c9d1e9 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
@@ -657,6 +657,16 @@ const notionMCPConnected = buildMCPServer({
enabled: true,
});
+const slackMCPAlwaysOnNeedingAuth = buildMCPServer({
+ id: "mcp-slack",
+ display_name: "Slack",
+ slug: "slack",
+ availability: "force_on",
+ auth_type: "oauth2",
+ auth_connected: false,
+ enabled: true,
+});
+
const mcpDefaults = {
chatOrganizationId: "org-1",
onMCPSelectionChange: fn(),
@@ -722,6 +732,19 @@ export const MCPGroupDisabled: Story = {
play: MCPGroupPopoverOpen.play,
};
+export const MCPGroupAlwaysOnNeedingAuth: Story = {
+ args: {
+ ...WithMCPServers.args,
+ mcpServers: [
+ sentryMCP,
+ linearMCP,
+ githubMCPConnected,
+ slackMCPAlwaysOnNeedingAuth,
+ ],
+ },
+ play: MCPGroupPopoverOpen.play,
+};
+
export const MCPGroupDisconnectDialog: Story = {
args: WithMCPServers.args,
play: async ({ canvasElement }) => {
diff --git a/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
index 709db78702eb9..50ec88fbac463 100644
--- a/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
+++ b/site/src/pages/AgentsPage/components/MCPServerToggleList.tsx
@@ -46,19 +46,25 @@ export const MCPServerToggleList: FC = ({
{server.display_name}
+ {isForceOn && (
+
+ )}
{needsAuth ? (
-
+ <>
+ {isForceOn && Always on}
+
+ >
) : (
<>
{server.auth_type === "oauth2" && (
@@ -73,9 +79,6 @@ export const MCPServerToggleList: FC = ({
)}
- {isForceOn && (
-
- )}
Date: Mon, 14 Sep 2026 11:51:07 +0000
Subject: [PATCH 8/8] feat(site/src/pages/AgentsPage): list only active servers
in the MCP pill popover
The grouped "N MCPs" pill listed every enabled server, duplicating the
plus menu. Show only the servers that make up the count so the popover
answers which MCPs are on; switching other servers on stays in the plus
menu. Alternative UI kept as its own commit so it can be reverted.
---
.../components/AgentChatInput.stories.tsx | 14 +++++++++++---
.../AgentsPage/components/AgentChatInput.test.tsx | 8 +++-----
.../pages/AgentsPage/components/AgentChatInput.tsx | 1 +
3 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
index 5917a90c9d1e9..0ffb1638b4cd0 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
@@ -714,8 +714,12 @@ export const WithTwoMCPServers: Story = {
},
};
+/** The pill lists only its active servers; Notion stays in the plus menu. */
export const MCPGroupPopoverOpen: Story = {
- args: WithMCPServers.args,
+ args: {
+ ...WithMCPServers.args,
+ mcpServers: [sentryMCP, linearMCP, githubMCPConnected, notionMCPConnected],
+ },
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "3 MCPs" }),
@@ -732,7 +736,7 @@ export const MCPGroupDisabled: Story = {
play: MCPGroupPopoverOpen.play,
};
-export const MCPGroupAlwaysOnNeedingAuth: Story = {
+export const PlusMenuAlwaysOnNeedingAuth: Story = {
args: {
...WithMCPServers.args,
mcpServers: [
@@ -742,7 +746,11 @@ export const MCPGroupAlwaysOnNeedingAuth: Story = {
slackMCPAlwaysOnNeedingAuth,
],
},
- play: MCPGroupPopoverOpen.play,
+ play: async ({ canvasElement }) => {
+ await userEvent.click(
+ within(canvasElement).getByRole("button", { name: "More options" }),
+ );
+ },
};
export const MCPGroupDisconnectDialog: Story = {
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
index dad6a321ca70e..1895bb3bc4a4e 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx
@@ -140,7 +140,7 @@ describe("AgentChatInput", () => {
]);
});
- it("enables an unselected MCP server from the group popover", async () => {
+ it("enables an unselected MCP server from the plus menu while the group is collapsed", async () => {
const user = userEvent.setup();
const onMCPSelectionChange = vi.fn();
renderInput(
@@ -152,11 +152,9 @@ describe("AgentChatInput", () => {
/>,
);
- await user.click(screen.getByRole("button", { name: "3 MCPs" }));
+ await user.click(screen.getByRole("button", { name: "More options" }));
await user.click(
- within(screen.getByRole("dialog")).getByRole("switch", {
- name: "Enable Notion",
- }),
+ await screen.findByRole("switch", { name: "Enable Notion" }),
);
expect(onMCPSelectionChange).toHaveBeenCalledWith([
mockSentryMCP.id,
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
index b11444624324a..ea2b859749b09 100644
--- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx
+++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx
@@ -278,6 +278,7 @@ const MCPGroupBadge: FC<{
{
setOpen(false);
mcpServerList.onDisconnect(server);