From f59e7d37c58d70e13c25c9b13e89616a9e470600 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:44:14 +0000 Subject: [PATCH 1/8] feat(site/src/pages/AgentsPage): group selected MCP servers --- .../components/AgentChatInput.stories.tsx | 119 ++++++++------ .../components/AgentChatInput.test.tsx | 153 +++++++++++++++++- .../AgentsPage/components/AgentChatInput.tsx | 62 ++++++- .../components/MCPServerIconStack.tsx | 60 +++++++ .../AgentsPage/components/MCPServerPicker.tsx | 66 +------- 5 files changed, 347 insertions(+), 113 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/MCPServerIconStack.tsx diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 4b94c9ddaa384..a213ed5261dbf 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MonitorDotIcon } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { type ComponentProps, useEffect, useRef } from "react"; import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; import { API } from "#/api/api"; import { preferenceSettingsKey } from "#/api/queries/users"; @@ -687,7 +687,7 @@ const startMCPOAuthFlow = async (canvasElement: HTMLElement) => { // ── MCP stories ──────────────────────────────────────────────── -/** Input with multiple MCP servers selected — shows icon stack in toolbar. */ +/** Multiple selected servers share one grouped pill with an icon stack. */ export const WithMCPServers: Story = { args: { ...mcpDefaults, @@ -696,6 +696,24 @@ export const WithMCPServers: Story = { }, }; +export const MCPGroupPopoverOpen: Story = { + args: WithMCPServers.args, + play: async ({ canvasElement }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { name: "3 MCP servers" }), + ); + await within(document.body).findByRole("dialog"); + }, +}; + +export const MCPGroupDisabled: Story = { + args: { + ...WithMCPServers.args, + isDisabled: true, + }, + play: MCPGroupPopoverOpen.play, +}; + /** MCP server needing OAuth — shows Auth button instead of toggle. */ export const WithMCPNeedingAuth: Story = { args: { @@ -1277,26 +1295,57 @@ const pagerdutyMCP = buildMCPServer({ enabled: true, }); -// Wide badges that cannot fit force into +N overflow. -const confluenceWideMCP = buildMCPServer({ - id: "mcp-confluence-wide", - display_name: "Confluence Cloud Enterprise Wiki", - slug: "confluence-wide", - availability: "default_on", - auth_type: "none", - enabled: true, -}); +const mockOverflowAttachedWorkspace = { + id: MockWorkspace.id, + name: "an-extremely-long-attached-workspace-name-that-cannot-fit-inline", + route: "/@admin/attached", + statusIcon: , + statusLabel: "Workspace running", +} satisfies ComponentProps["attachedWorkspace"]; -const datadogWideMCP = buildMCPServer({ - id: "mcp-datadog-wide", - display_name: "Datadog Infrastructure Monitoring", - slug: "datadog-wide", - availability: "default_on", - auth_type: "none", - enabled: true, -}); +export const MCPGroupMoreThanThree: Story = { + args: { + ...mcpDefaults, + mcpServers: [ + sentryMCP, + linearMCP, + githubMCPConnected, + notionMCPConnected, + confluenceMCP, + ], + selectedMCPServerIds: [ + sentryMCP.id, + linearMCP.id, + githubMCPConnected.id, + notionMCPConnected.id, + confluenceMCP.id, + ], + }, +}; + +export const MCPGroupInOverflow: Story = { + args: { + ...WithMCPServers.args, + attachedWorkspace: mockOverflowAttachedWorkspace, + onWorkspaceChange: fn(), + }, + parameters: { + viewport: { defaultViewport: "mobile2" }, + pixel: { matrix: { viewports: ["phone"] } }, + }, + play: async ({ canvasElement }) => { + await userEvent.click( + await within(canvasElement).findByRole("button", { name: /more item/ }), + ); + const overflow = await within(document.body).findByRole("dialog"); + await userEvent.click( + within(overflow).getByRole("button", { name: "3 MCP servers" }), + ); + await within(document.body).findByRole("button", { name: "Remove Linear" }); + }, +}; -/** Many tools with a workspace at 414px — forces overflow and "+N" pill. */ +/** A long attached workspace keeps the grouped tools in the "+N" overflow. */ export const OverflowBadges: Story = { args: { ...mcpDefaults, @@ -1316,31 +1365,8 @@ export const OverflowBadges: Story = { datadogMCP.id, pagerdutyMCP.id, ], - workspaceOptions: [ - { - id: "ws-1", - name: "my-long-workspace-name", - owner_name: "admin", - organization_id: "org-1", - }, - ], - selectedWorkspaceId: "ws-1", + attachedWorkspace: mockOverflowAttachedWorkspace, onWorkspaceChange: fn(), - attachedWorkspace: { - id: "ws-1", - name: "my-long-workspace-name", - route: "/@admin/my-long-workspace-name", - statusIcon: , - statusLabel: "Workspace running", - }, - workspace: { - ...MockWorkspace, - id: "ws-1", - name: "my-long-workspace-name", - owner_name: "admin", - }, - workspaceAgent: MockWorkspaceAgent, - chatId: "overflow-chat-id", }, parameters: { viewport: { defaultViewport: "mobile2" }, @@ -1552,8 +1578,9 @@ export const ModelExpandsWhileBadgesOverflow: Story = { displayName: "Claude Sonnet 4.5", }, ], - mcpServers: [confluenceWideMCP, datadogWideMCP], - selectedMCPServerIds: [confluenceWideMCP.id, datadogWideMCP.id], + mcpServers: [confluenceMCP, datadogMCP], + selectedMCPServerIds: [confluenceMCP.id, datadogMCP.id], + attachedWorkspace: mockOverflowAttachedWorkspace, }, parameters: { viewport: { defaultViewport: "mobile2" }, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx index f74c4557c092f..63d3ce3125748 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx @@ -1,8 +1,10 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createRef, type ReactNode } from "react"; +import { type ComponentProps, createRef, type ReactNode } from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { AppProviders } from "#/App"; +import type * as TypesGen from "#/api/typesGenerated"; +import { MockMCPServerConfig } from "#/testHelpers/chatEntities"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; vi.mock("#/modules/dashboard/useDashboard", () => ({ @@ -18,6 +20,52 @@ const modelOptions = [ }, ] as const; +const inputProps = { + onSend: vi.fn(), + isDisabled: false, + isLoading: false, + selectedModel: modelOptions[0].id, + onModelChange: vi.fn(), + modelOptions, + modelSelectorPlaceholder: "Select model", + hasModelOptions: true, + canConfigureAgentSetup: false, +} satisfies ComponentProps; + +const mockSentryMCP: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-sentry", + display_name: "Sentry", + availability: "force_on", + auth_type: "oauth2", + auth_connected: true, +}; + +const mockLinearMCP: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-linear", + display_name: "Linear", + availability: "default_on", + auth_type: "api_key", +}; + +const mockGitHubMCP: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-github", + display_name: "GitHub", + availability: "default_on", + auth_type: "oauth2", + auth_connected: true, +}; + +const mockGitHubMCPNeedingAuth: TypesGen.MCPServerConfig = { + ...mockGitHubMCP, + auth_connected: false, +}; + +const mockMCPServers = [mockSentryMCP, mockLinearMCP, mockGitHubMCP]; +const mockSelectedMCPServerIds = mockMCPServers.map((server) => server.id); + const renderInput = (children: ReactNode) => { return render({children}); }; @@ -58,4 +106,105 @@ describe("AgentChatInput", () => { await user.keyboard("{Enter}"); expect(onSend).not.toHaveBeenCalled(); }); + + it("removes a selected MCP server from the group", async () => { + const user = userEvent.setup(); + const onMCPSelectionChange = vi.fn(); + renderInput( + , + ); + + await user.click(screen.getByRole("button", { name: "3 MCP servers" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Remove Linear", + }), + ); + expect(onMCPSelectionChange).toHaveBeenCalledWith([ + mockSentryMCP.id, + mockGitHubMCP.id, + ]); + }); + + 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(); + renderInput( + , + ); + + expect(screen.queryByRole("button", { name: /MCP servers/ })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Remove Linear" })); + expect(onMCPSelectionChange).toHaveBeenCalledWith([]); + }); + + it("excludes selected MCP servers that still need OAuth from the group", async () => { + const user = userEvent.setup(); + const onMCPSelectionChange = vi.fn(); + renderInput( + , + ); + + expect(screen.queryByRole("button", { name: /MCP servers/ })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Remove Linear" })); + expect(onMCPSelectionChange).toHaveBeenCalledWith([ + mockGitHubMCPNeedingAuth.id, + ]); + }); + + it("allows viewing a disabled MCP group without changing its selection", async () => { + const user = userEvent.setup(); + const onMCPSelectionChange = vi.fn(); + renderInput( + , + ); + + await user.click(screen.getByRole("button", { name: "3 MCP servers" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Remove Linear", + }), + ); + expect(onMCPSelectionChange).not.toHaveBeenCalled(); + }); }); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 7106939afb4d6..84202ad94025a 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -4,6 +4,7 @@ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, + LockIcon, MicIcon, MonitorIcon, PaperclipIcon, @@ -90,6 +91,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 { QueuedMessagesList } from "./QueuedMessagesList"; import { TextPreviewDialog } from "./TextPreviewDialog"; import { WorkspacePill } from "./WorkspacePill"; @@ -222,6 +224,7 @@ type ToolBadgeData = | { kind: "workspace"; name: string } | ({ kind: "attached-workspace" } & AttachedWorkspaceInfo) | { kind: "mcp"; server: TypesGen.MCPServerConfig } + | { kind: "mcp-group"; servers: readonly TypesGen.MCPServerConfig[] } | { kind: "planning" }; // Small `X` button rendered inside pill-style badges (attached @@ -337,6 +340,53 @@ 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" ? ( + + ) : ( + onRemoveMcp && ( + onRemoveMcp(server.id)} + isDisabled={isDisabled} + /> + ) + )} +
+ ))} +
+
+ ); + } + const isForceOn = badge.server.availability === "force_on"; return ( @@ -631,8 +681,12 @@ export const AgentChatInput: FC = ({ if (shouldShowSelectedWorkspaceBadge && selectedWorkspace) { allBadges.push({ kind: "workspace", name: selectedWorkspace.name }); } - for (const s of activeMcpServers) { - allBadges.push({ kind: "mcp", server: s }); + if (activeMcpServers.length > 1) { + allBadges.push({ kind: "mcp-group", servers: activeMcpServers }); + } else { + for (const server of activeMcpServers) { + allBadges.push({ kind: "mcp", server }); + } } const overflowCount = useOverflowCount(badgeContainerRef, allBadges.length); @@ -1597,7 +1651,9 @@ export const AgentChatInput: FC = ({ key={ badge.kind === "mcp" ? badge.server.id - : `${badge.kind}-overflow-${visibleCount + i}` + : badge.kind === "mcp-group" + ? badge.kind + : `${badge.kind}-overflow-${visibleCount + i}` } badge={badge} onRemoveWorkspace={removeWorkspaceHandler} diff --git a/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx b/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx new file mode 100644 index 0000000000000..73d8358c5d1b8 --- /dev/null +++ b/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx @@ -0,0 +1,60 @@ +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"; + +export const MCPServerIcon: FC<{ + iconUrl: string; + name: string; + className?: string; +}> = ({ iconUrl, name, className }) => { + const icon = iconUrl ? ( + + ) : ( + + ); + + return ( +
+ {icon} +
+ ); +}; + +const ICON_STACK_MAX = 3; + +export const MCPServerIconStack: FC<{ + servers: readonly TypesGen.MCPServerConfig[]; +}> = ({ servers }) => { + const visible = servers.slice(0, ICON_STACK_MAX); + return ( + + {visible.map((s, i) => ( + 0 && "-ml-1.5", + )} + > + + + ))} + {servers.length > ICON_STACK_MAX && ( + + +{servers.length - ICON_STACK_MAX} + + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 33392552b9d9e..96c47d04ca60d 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -1,10 +1,8 @@ -import { cn } from "cn"; -import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react"; +import { ChevronDownIcon, LockIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; import { mcpServerOAuth2ConnectPath } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { Popover, PopoverContent, @@ -18,6 +16,7 @@ import { TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { MCPServerIcon, MCPServerIconStack } from "./MCPServerIconStack"; // ── Types ────────────────────────────────────────────────────── @@ -50,29 +49,6 @@ const availabilityLabel = (a: string) => { } }; -const MCPIcon: FC<{ iconUrl: string; name: string; className?: string }> = ({ - iconUrl, - name, - className, -}) => { - const icon = iconUrl ? ( - - ) : ( - - ); - - return ( -
- {icon} -
- ); -}; - /** * Compute the default selection based on server availability policies. * force_on and default_on servers are selected by default. @@ -172,40 +148,6 @@ export const saveMCPSelection = ( ); }; -// ── Overlapping icon stack for the trigger ───────────────────── - -const ICON_STACK_MAX = 3; - -const TriggerIconStack: FC<{ - servers: readonly TypesGen.MCPServerConfig[]; -}> = ({ servers }) => { - const visible = servers.slice(0, ICON_STACK_MAX); - return ( - - {visible.map((s, i) => ( - 0 && "-ml-1.5", - )} - > - - - ))} - {servers.length > ICON_STACK_MAX && ( - - +{servers.length - ICON_STACK_MAX} - - )} - - ); -}; - // ── Component ────────────────────────────────────────────────── export const MCPServerPicker: FC = ({ @@ -303,7 +245,7 @@ export const MCPServerPicker: FC = ({ > MCP {activeServers.length > 0 && ( - + )} @@ -323,7 +265,7 @@ export const MCPServerPicker: FC = ({
- Date: Fri, 11 Sep 2026 23:30:22 +0000 Subject: [PATCH 2/8] refactor(site/src/pages/AgentsPage): make MCP server icons decorative Every MCPServerIcon consumer renders the server name as text or labels the containing control, so the image alt text only duplicated the name for screen readers. --- .../pages/AgentsPage/components/AgentChatInput.tsx | 6 +----- .../AgentsPage/components/MCPServerIconStack.tsx | 13 +++++-------- .../pages/AgentsPage/components/MCPServerPicker.tsx | 1 - 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 84202ad94025a..b8b330e77db39 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -363,11 +363,7 @@ 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 73d8358c5d1b8..ff176dd0cc710 100644 --- a/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerIconStack.tsx @@ -4,13 +4,14 @@ 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; - name: string; className?: string; -}> = ({ iconUrl, name, className }) => { +}> = ({ iconUrl, className }) => { const icon = iconUrl ? ( - + ) : ( ); @@ -43,11 +44,7 @@ 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 96c47d04ca60d..afe6552ac4157 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -267,7 +267,6 @@ export const MCPServerPicker: FC = ({
From dcdc6267ce1d8f409b9c7466de061274fda00aca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:51:45 +0000 Subject: [PATCH 3/8] refactor(site): share MCPServerIcon between settings and the composer Move the MCP server icon from the settings page into modules/mcpServers with a tile/circle variant so the composer and picker reuse it instead of keeping a second renderer. Drop the presence-only force-on test; the open-group story already captures the locked row. --- site/src/modules/mcpServers/MCPServerIcon.tsx | 33 +++++++++++++++++++ .../components/MCPServerFormHeader.tsx | 6 ++-- .../components/MCPServerIcon.tsx | 29 ---------------- .../components/MCPServerRow.tsx | 3 +- .../components/AgentChatInput.test.tsx | 19 ----------- .../AgentsPage/components/AgentChatInput.tsx | 9 +++-- .../components/MCPServerIconStack.tsx | 33 ++++--------------- .../AgentsPage/components/MCPServerPicker.tsx | 4 ++- 8 files changed, 52 insertions(+), 84 deletions(-) create mode 100644 site/src/modules/mcpServers/MCPServerIcon.tsx delete mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerIcon.tsx diff --git a/site/src/modules/mcpServers/MCPServerIcon.tsx b/site/src/modules/mcpServers/MCPServerIcon.tsx new file mode 100644 index 0000000000000..6faf4338cc726 --- /dev/null +++ b/site/src/modules/mcpServers/MCPServerIcon.tsx @@ -0,0 +1,33 @@ +import { cn } from "cn"; +import { ServerIcon } from "lucide-react"; +import type { FC } from "react"; +import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; + +const variantClasses = { + tile: "rounded border border-solid border-border", + circle: "rounded-full", +}; + +// 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; + variant?: keyof typeof variantClasses; +}> = ({ iconUrl, className, variant = "tile" }) => { + return ( +
+ {iconUrl ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx index de75a5c10889e..fa1c5022b68ac 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx @@ -12,7 +12,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { MCPServerIcon } from "./MCPServerIcon"; +import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon"; const MCPServerFormBackLink: FC<{ to: string }> = ({ to }) => { return ( @@ -82,9 +82,7 @@ export const MCPServerFormHeader: FC = ({ )}
- {isEditing && ( - - )} + {isEditing && } = ({ iconUrl, name, className }) => { - return ( -
- {iconUrl ? ( - - ) : ( - - )} -
- ); -}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx index 428de1fc0e14a..3d242f939986f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx @@ -5,7 +5,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; import { TableCell, TableRow } from "#/components/Table/Table"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; -import { MCPServerIcon } from "./MCPServerIcon"; +import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon"; import { AUTH_TYPE_LABELS, AVAILABILITY_LABELS } from "./mcpServerFormLogic"; interface MCPServerRowProps { @@ -25,7 +25,6 @@ export const MCPServerRow: FC = ({ server, onClick }) => {
{ ]); }); - 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);