From 7f4127bc61a59068b6619e493d2c877144ceaa82 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 21 Apr 2026 17:53:17 +0100 Subject: [PATCH] feat(site/src/pages/AgentsPage): show MCP settings and hide insights in sidebar (#24428) --- .../MCPServerAdminPanel.stories.tsx | 89 +- .../components/MCPServerAdminPanel.tsx | 1322 ++++++++++------- .../components/Sidebar/AgentsSidebar.tsx | 18 +- 3 files changed, 877 insertions(+), 552 deletions(-) diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx index a88d84038f..d7cf50f4ff 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx @@ -81,8 +81,14 @@ export const EmptyState: Story = { await expect( await body.findByText(/No MCP servers configured yet/i), ).toBeInTheDocument(); - await expect( - body.getByRole("button", { name: /Add Server/i }), + + // Both the section header and the empty state render a distinct + // Add button, and the empty-state one is the primary CTA. + expect( + body.getByRole("button", { name: "Add server" }), + ).toBeInTheDocument(); + expect( + body.getByRole("button", { name: "Add your first server" }), ).toBeInTheDocument(); }, }; @@ -137,6 +143,9 @@ export const ServerList: Story = { ).toBeInTheDocument(); expect(body.getByRole("button", { name: /Linear/ })).toBeInTheDocument(); expect(body.getByRole("button", { name: /GitHub/ })).toBeInTheDocument(); + + // Disabled servers surface a warning badge next to the name. + expect(body.getByText(/^disabled$/i)).toBeInTheDocument(); }, }; @@ -150,14 +159,26 @@ export const CreateServer: Story = { // Click Add Server. await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /Add your first server/i }), ); // Fill in the display name via the inline header input. const nameInput = await body.findByLabelText(/Display Name/i); await userEvent.type(nameInput, "Sentry"); - // Slug should auto-populate. + // Required fields (slug, server URL) are always visible. Optional + // sections start collapsed and the Enabled switch is edit-only. + // PencilIcon sits next to the editable name. + expect(body.getByLabelText(/^Slug/i)).toBeInTheDocument(); + expect(body.getByLabelText(/Server URL/i)).toBeInTheDocument(); + expect(body.queryByLabelText(/Description/i)).not.toBeInTheDocument(); + expect( + body.queryByRole("switch", { name: /Enabled/i }), + ).not.toBeInTheDocument(); + const nameRow = nameInput.closest("div")?.parentElement; + expect(nameRow?.querySelector("svg.lucide-pencil")).toBeTruthy(); + + // Slug should auto-populate from the display name. await expect(body.getByLabelText(/^Slug/i)).toHaveValue("sentry"); await userEvent.type( @@ -192,7 +213,7 @@ export const CreateServerOAuth2: Story = { const body = within(canvasElement.ownerDocument.body); await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /Add your first server/i }), ); await userEvent.type(await body.findByLabelText(/Display Name/i), "GitHub"); @@ -201,6 +222,9 @@ export const CreateServerOAuth2: Story = { "https://api.githubcopilot.com/mcp/", ); + await userEvent.click( + await body.findByRole("button", { name: /Authentication/i }), + ); // Select OAuth2 from the Radix Select dropdown. await userEvent.click(body.getByLabelText(/Authentication/i)); await userEvent.click(await body.findByRole("option", { name: /OAuth2/i })); @@ -241,7 +265,7 @@ export const CreateServerAPIKey: Story = { const body = within(canvasElement.ownerDocument.body); await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /Add your first server/i }), ); await userEvent.type(await body.findByLabelText(/Display Name/i), "Linear"); @@ -250,6 +274,9 @@ export const CreateServerAPIKey: Story = { "https://mcp.linear.app/v1", ); + await userEvent.click( + await body.findByRole("button", { name: /Authentication/i }), + ); // Select API Key from the Radix Select dropdown. await userEvent.click(body.getByLabelText(/Authentication/i)); await userEvent.click( @@ -306,11 +333,16 @@ export const EditServer: Story = { // The inline name input should be pre-populated. const nameInput = await body.findByLabelText(/Display Name/i); expect(nameInput).toHaveValue("Sentry"); + + // Slug and Server URL are always visible. expect(body.getByLabelText(/^Slug/i)).toHaveValue("sentry"); expect(body.getByLabelText(/Server URL/i)).toHaveValue( "https://mcp.sentry.io/sse", ); + // Expand Details to reach the description field. + await userEvent.click(body.getByRole("button", { name: /Details/i })); + // Update the description. const descField = body.getByLabelText(/Description/i); await userEvent.clear(descField); @@ -355,6 +387,11 @@ export const EditServerWithOAuth2Secret: Story = { await userEvent.click(await body.findByRole("button", { name: /GitHub/ })); + // Authentication section is collapsed by default. + await userEvent.click( + await body.findByRole("button", { name: /Authentication/i }), + ); + // The OAuth2 fields should be visible. const secretField = await body.findByLabelText(/Client Secret/i); expect(secretField).toHaveValue("••••••••••••••••"); @@ -385,6 +422,11 @@ export const EditServerWithCustomHeaders: Story = { await body.findByRole("button", { name: /Custom API/ }), ); + // Authentication section is collapsed by default. + await userEvent.click( + await body.findByRole("button", { name: /Authentication/i }), + ); + // Should show message about existing headers. await expect( await body.findByText(/has custom headers configured/i), @@ -477,6 +519,31 @@ export const DeleteServerCancelled: Story = { }; /** Confirm delete in dialog calls the API. */ +export const DirectEditWhileLoading: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/settings/mcp-servers", + searchParams: { server: "mcp-sentry" }, + }, + routing: { path: "/agents/settings/mcp-servers" }, + }), + }, + args: { + serversData: undefined, + isLoadingServers: true, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await expect(await body.findByText(/^Loading$/i)).toBeInTheDocument(); + expect(body.queryByLabelText(/Display Name/i)).not.toBeInTheDocument(); + expect( + body.queryByRole("button", { name: /Save changes/i }), + ).not.toBeInTheDocument(); + }, +}; + export const DeleteServerConfirmed: Story = { args: { serversData: [ @@ -519,7 +586,7 @@ export const BackToList: Story = { const body = within(canvasElement.ownerDocument.body); await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /^Add server$/i }), ); // Click Back. @@ -543,7 +610,7 @@ export const CreateServerWithToolGovernance: Story = { const body = within(canvasElement.ownerDocument.body); await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /Add your first server/i }), ); await userEvent.type( @@ -555,6 +622,7 @@ export const CreateServerWithToolGovernance: Story = { "https://mcp.example.com/v1", ); + await userEvent.click(body.getByRole("button", { name: /Behavior/i })); await userEvent.type( body.getByLabelText(/Tool Allow List/i), "search, read_file", @@ -587,7 +655,7 @@ export const CustomHeadersAuthType: Story = { const body = within(canvasElement.ownerDocument.body); await userEvent.click( - await body.findByRole("button", { name: /Add Server/i }), + await body.findByRole("button", { name: /Add your first server/i }), ); await userEvent.type( @@ -599,6 +667,9 @@ export const CustomHeadersAuthType: Story = { "https://mcp.example.com/v1", ); + await userEvent.click( + await body.findByRole("button", { name: /Authentication/i }), + ); // Select Custom Headers auth type. await userEvent.click(body.getByLabelText(/Authentication/i)); await userEvent.click( diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx index 1b3d12c6ae..59c9f30a50 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx @@ -1,22 +1,48 @@ import { useFormik } from "formik"; import { CheckCircleIcon, + ChevronDownIcon, ChevronRightIcon, CircleIcon, + PencilIcon, PlusIcon, ServerIcon, XIcon, } from "lucide-react"; -import { type FC, type ReactNode, useId, useState } from "react"; -import { useSearchParams } from "react-router"; +import { + type FC, + lazy, + type ReactNode, + Suspense, + useId, + useState, +} from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; +import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; +import { Loader } from "#/components/Loader/Loader"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "#/components/Popover/Popover"; import { Select, SelectContent, @@ -127,6 +153,88 @@ const MCPServerIcon: FC<{ ); }; +// ── Icon picker ────────────────────────────────────────────────── + +const EmojiPicker = lazy(() => import("#/components/IconField/EmojiPicker")); + +interface IconPickerFieldProps { + id?: string; + value: string; + placeholder?: string; + disabled?: boolean; + onChange: (value: string) => void; + onPickEmoji: (value: string) => void; +} + +const IconPickerField: FC = ({ + id, + value, + placeholder, + disabled, + onChange, + onPickEmoji, +}) => { + const [open, setOpen] = useState(false); + const hasIcon = value !== ""; + + return ( + + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled" + spellCheck={false} + /> + + {hasIcon && ( + + { + e.currentTarget.style.display = "none"; + }} + onLoad={(e) => { + e.currentTarget.style.display = "inline"; + }} + /> + + )} + + + + + + }> + { + const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; + onPickEmoji(picked); + setOpen(false); + }} + /> + + + + + + ); +}; + // ── Server List ──────────────────────────────────────────────── interface ServerListProps { @@ -145,65 +253,85 @@ const ServerList: FC = ({ sectionLabel, sectionDescription, sectionBadge, -}) => ( - <> - - - Add Server - - } - /> +}) => { + return ( + <> + + + Add server + + } + /> - {servers.length === 0 ? ( -
- No MCP servers configured yet. Add a server to get started. -
- ) : ( -
- {servers.map((server, i) => ( - - ))} -
- )} - -); + {servers.length === 0 ? ( +
+

+ No MCP servers configured yet. +

+ +
+ ) : ( +
+ {servers.map((server, i) => ( + + ))} +
+ )} + + ); +}; // ── Server Form ──────────────────────────────────────────────── @@ -288,6 +416,9 @@ const ServerForm: FC = ({ const formId = useId(); const isEditing = server !== null; const [confirmingDelete, setConfirmingDelete] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const [showAuth, setShowAuth] = useState(false); + const [showBehavior, setShowBehavior] = useState(false); const form = useFormik({ initialValues: buildInitialValues(server), @@ -356,59 +487,68 @@ const ServerForm: FC = ({
- { - form.setFieldValue("displayName", e.target.value); - if (!form.values.slugTouched) { - form.setFieldValue("slug", slugify(e.target.value)); - } - }} - disabled={isDisabled} - className="m-0 min-w-0 flex-1 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0" - placeholder="Server display name" - aria-label="Display Name" - /> - - - - { - form.setFieldValue("enabled", v); - }} - aria-label="Enabled" - disabled={isDisabled} - /> +
+
+ - - - {form.values.enabled ? "Disable" : "Enable"} this server - - + { + form.setFieldValue("displayName", e.target.value); + if (!form.values.slugTouched) { + form.setFieldValue("slug", slugify(e.target.value)); + } + }} + disabled={isDisabled} + spellCheck={false} + className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0" + placeholder="Server display name" + aria-label="Display Name" + /> +
+ +
+ {isEditing && ( + + + + { + form.setFieldValue("enabled", v); + }} + aria-label="Enabled" + disabled={isDisabled} + /> + + + + {form.values.enabled + ? "Disable this server. It will be hidden from agents." + : "Enable this server. It will be visible to agents."} + + + )}

-
- {" "} - {/* ── Identity row: slug + description side by side ── */} -
- {" "} - +
+
+ = ({ disabled={isDisabled} /> - - - -
- - { - form.setFieldValue("iconURL", e.target.value); - }} - onPickEmoji={(value) => { - form.setFieldValue("iconURL", value); - }} - disabled={isDisabled} - /> - - {/* ── Connection row: URL + transport side by side ── */} -
-
- - - +
+ + + - - - -
- {/* ── Authentication ── */} -
- - - - {form.values.authType === "oauth2" && ( -
-

- Register a client with the external MCP server's OAuth2 provider - and enter the credentials below. Coder will handle the per-user - authorization flow. -

-
- {" "} - - - - - { - form.setFieldValue("oauth2SecretTouched", true); - form.setFieldValue("oauth2ClientSecret", e.target.value); - }} - onFocus={() => { - if ( - !form.values.oauth2SecretTouched && - form.values.oauth2ClientSecret === SECRET_PLACEHOLDER - ) { - form.setFieldValue("oauth2ClientSecret", ""); - form.setFieldValue("oauth2SecretTouched", true); - } - }} - disabled={isDisabled} - /> - -
-
- - - - - - -
- - - -
- )} - {form.values.authType === "api_key" && ( -
- - - - - { - form.setFieldValue("apiKeyTouched", true); - form.setFieldValue("apiKeyValue", e.target.value); - }} - onFocus={() => { - if ( - !form.values.apiKeyTouched && - form.values.apiKeyValue === SECRET_PLACEHOLDER - ) { - form.setFieldValue("apiKeyValue", ""); - form.setFieldValue("apiKeyTouched", true); - } + + { - form.setFieldValue("customHeadersTouched", true); - const updated = [...form.values.customHeaders]; - updated[index] = { - ...updated[index], - key: e.target.value, - }; - form.setFieldValue("customHeaders", updated); - }} - placeholder="Header name" - disabled={isDisabled} - aria-label={`Header ${index + 1} name`} - /> - { - form.setFieldValue("customHeadersTouched", true); - const updated = [...form.values.customHeaders]; - updated[index] = { - ...updated[index], - value: e.target.value, - }; - form.setFieldValue("customHeaders", updated); - }} - placeholder="Header value" - disabled={isDisabled} - aria-label={`Header ${index + 1} value`} - /> -
- -
- ))} - + + + + {TRANSPORT_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + +
- )} - {/* ── Availability ── */} -
- - + + + { + form.setFieldValue("iconURL", v); + }} + onPickEmoji={(v) => { + form.setFieldValue("iconURL", v); + }} + disabled={isDisabled} + /> + + +
+ + + {/* ── Authentication section ── */} + +
+ + + + + + + + {form.values.authType === "oauth2" && ( +
+

+ Register a client with the external MCP server's OAuth2 + provider and enter the credentials below. Coder will + handle the per-user authorization flow. +

+
+ + + + + { + form.setFieldValue("oauth2SecretTouched", true); + form.setFieldValue( + "oauth2ClientSecret", + e.target.value, + ); + }} + onFocus={() => { + if ( + !form.values.oauth2SecretTouched && + form.values.oauth2ClientSecret === + SECRET_PLACEHOLDER + ) { + form.setFieldValue("oauth2ClientSecret", ""); + form.setFieldValue("oauth2SecretTouched", true); + } + }} + disabled={isDisabled} + /> +
- - ))} - - - {" "} - {/* ── Tool governance row ── */} -
-
-
- -

- Require the model to describe each tool call's purpose in - natural language, shown as a status label in the UI. -

+
+ + + + + + +
+ + + +
+ )} + + {form.values.authType === "api_key" && ( +
+ + + + + { + form.setFieldValue("apiKeyTouched", true); + form.setFieldValue("apiKeyValue", e.target.value); + }} + onFocus={() => { + if ( + !form.values.apiKeyTouched && + form.values.apiKeyValue === SECRET_PLACEHOLDER + ) { + form.setFieldValue("apiKeyValue", ""); + form.setFieldValue("apiKeyTouched", true); + } + }} + disabled={isDisabled} + /> + +
+ )} + + {form.values.authType === "custom_headers" && ( +
+ {server?.has_custom_headers && + !form.values.customHeadersTouched && ( +

+ This server has custom headers configured. Add headers + below to replace them. +

+ )} + {form.values.customHeaders.map((header, index) => ( +
+
+ { + form.setFieldValue("customHeadersTouched", true); + const updated = [...form.values.customHeaders]; + updated[index] = { + ...updated[index], + key: e.target.value, + }; + form.setFieldValue("customHeaders", updated); + }} + placeholder="Header name" + disabled={isDisabled} + aria-label={`Header ${index + 1} name`} + /> + { + form.setFieldValue("customHeadersTouched", true); + const updated = [...form.values.customHeaders]; + updated[index] = { + ...updated[index], + value: e.target.value, + }; + form.setFieldValue("customHeaders", updated); + }} + placeholder="Header value" + disabled={isDisabled} + aria-label={`Header ${index + 1} value`} + /> +
+ +
+ ))} + +
+ )} +
- { - form.setFieldValue("modelIntent", v); - }} - disabled={isDisabled} - /> -
-
-
- -

- When enabled, the root plan-mode agent can call these tools - during planning. Workspace MCP and plan-mode subagents remain - restricted. -

+ + {/* ── Behavior section ── */} + +
+ + + + + + + + +
+
+

+ Model intent +

+

+ Require the model to describe each tool call's purpose in + natural language, shown as a status label in the UI. +

+
+ { + form.setFieldValue("modelIntent", v); + }} + disabled={isDisabled} + /> +
+ +
+
+ +

+ When enabled, the root plan-mode agent can call these + tools during planning. Workspace MCP and plan-mode + subagents remain restricted. +

+
+ { + form.setFieldValue("allowInPlanMode", v); + }} + disabled={isDisabled} + /> +
+ +
+ + + + + + +
+
- { - form.setFieldValue("allowInPlanMode", v); - }} - disabled={isDisabled} - /> -
-
- {" "} - - - - - - -
+
- {/* Footer — pushed to bottom, matches ProviderForm */} -
+

{isEditing ? ( @@ -848,7 +1065,14 @@ const ServerForm: FC = ({ Delete ) : ( -
+ )}
); }; @@ -873,6 +1097,7 @@ const ServerForm: FC = ({ // ── Main Panel ───────────────────────────────────────────────── interface MCPServerAdminPanelProps { + className?: string; sectionLabel?: string; sectionDescription?: string; sectionBadge?: ReactNode; @@ -898,6 +1123,7 @@ interface MCPServerAdminPanelProps { } export const MCPServerAdminPanel: FC = ({ + className, sectionLabel, sectionDescription, sectionBadge, @@ -916,6 +1142,22 @@ export const MCPServerAdminPanel: FC = ({ }) => { const [searchParams, setSearchParams] = useSearchParams(); const serverId = searchParams.get("server"); + const navigate = useNavigate(); + const location = useLocation(); + // Whether the current form entry was pushed by an in-app click + // (as opposed to a direct-entry URL like a bookmark or shared link). + // When true, navigate(-1) is safe; otherwise we fall back to + // clearing params with replace to avoid leaving the app. + const canGoBack = + (location.state as { pushed?: boolean } | null)?.pushed === true; + + const exitServerView = () => { + if (canGoBack) { + navigate(-1); + } else { + setSearchParams({}, { replace: true }); + } + }; const servers = (serversData ?? []) .slice() @@ -956,7 +1198,7 @@ export const MCPServerAdminPanel: FC = ({ return; } } - setSearchParams({}); + exitServerView(); }; const handleDelete = async (id: string) => { @@ -966,35 +1208,47 @@ export const MCPServerAdminPanel: FC = ({ // Error surfaced via mutation error state. return; } - setSearchParams({}); + exitServerView(); }; - if (isLoadingServers) { - return ; - } - return ( -
- {!isFormView ? ( - setSearchParams({ server: server.id })} - onAdd={() => setSearchParams({ server: "new" })} - sectionLabel={sectionLabel} - sectionDescription={sectionDescription} - sectionBadge={sectionBadge} - /> - ) : ( - setSearchParams({})} - /> +
+ {isLoadingServers && ( +
+ + Loading +
)} + {/* Content */} +
+ {!isFormView ? ( + + setSearchParams( + { server: server.id }, + { state: { pushed: true } }, + ) + } + onAdd={() => + setSearchParams({ server: "new" }, { state: { pushed: true } }) + } + sectionLabel={sectionLabel} + sectionDescription={sectionDescription} + sectionBadge={sectionBadge} + /> + ) : isCreating || (!isLoadingServers && editingServer) ? ( + + ) : null} +
{serversError && } {createError && } diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index b98b63d952..1cd990c8ae 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -39,13 +39,13 @@ import { PauseIcon, PinIcon, PinOffIcon, + ServerIcon, SettingsIcon, ShieldIcon, SquarePenIcon, Trash2Icon, UserIcon, WalletIcon, - WandSparklesIcon, } from "lucide-react"; import { createContext, @@ -1360,18 +1360,18 @@ export const AgentsSidebar: FC = (props) => { adminOnly />