feat: move MCP servers to AI settings (#26642)

This commit is contained in:
Danielle Maywood
2026-06-24 12:30:15 +01:00
committed by GitHub
parent 3133a8b9c6
commit 85652554f9
31 changed files with 2212 additions and 2109 deletions
@@ -4,13 +4,12 @@ Administrators can register external MCP servers that provide additional tools
for agent chat sessions. Configured servers are injected into or offered to
users during chat depending on the availability policy.
This is an admin-only feature accessible at **Agents** > **Settings** >
**Manage Agents** > **MCP Servers**.
This is an admin-only feature accessible at **AI Settings** > **MCP servers**
(`/ai/settings/mcp-servers`).
## Add an MCP server
1. Navigate to **Agents** > **Settings** > **Manage Agents** >
**MCP Servers**.
1. Navigate to **AI Settings** > **MCP servers**.
1. Click **Add**.
1. Fill in the configuration fields described below.
1. Click **Save**.
+3 -1
View File
@@ -28,7 +28,9 @@ export const Navbar: FC = () => {
const canViewAIBridge =
featureVisibility.aibridge && permissions.viewAnyAIBridgeInterception;
const canViewAISettings =
permissions.viewAnyAIProvider || permissions.viewAIGatewayKeys;
permissions.viewAnyAIProvider ||
permissions.viewAIGatewayKeys ||
permissions.editDeploymentConfig;
const canCreateChat = permissions.createChat;
const uniqueLinks = new Map<string, LinkConfig>();
@@ -50,6 +50,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
Templates
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/mcp-servers">
MCP servers
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/agents/settings/agents">
<div className="flex flex-row items-center gap-1">
@@ -0,0 +1,36 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { createMCPServerConfig } from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import AddMCPServerPageView from "./AddMCPServerPageView";
const AddMCPServerPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const navigate = useNavigate();
const createMutation = useMutation(createMCPServerConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AddMCPServerPageView
isSaving={createMutation.isPending}
onCancel={() => void navigate("/ai/settings/mcp-servers")}
onCreateServer={async (req) => {
try {
const server = await createMutation.mutateAsync(req);
toast.success(`MCP server "${server.display_name}" added.`);
await navigate(`/ai/settings/mcp-servers/${server.id}`);
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add MCP server."));
}
}}
/>
</RequirePermission>
);
};
export default AddMCPServerPage;
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
import AddMCPServerPageView from "./AddMCPServerPageView";
const meta: Meta<typeof AddMCPServerPageView> = {
title: "pages/AISettingsPage/MCPServersPage/AddMCPServerPageView",
component: AddMCPServerPageView,
args: {
isSaving: false,
onCreateServer: fn(
async (req: TypesGen.CreateMCPServerConfigRequest) =>
({ ...MockMCPServerConfig, ...req }) as TypesGen.MCPServerConfig,
),
onCancel: fn(),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers/add" },
routing: { path: "/ai/settings/mcp-servers/add" },
}),
},
};
export default meta;
type Story = StoryObj<typeof AddMCPServerPageView>;
export const Default: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const addButton = canvas.getByRole("button", { name: "Add server" });
await expect(addButton).toBeDisabled();
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("github");
await userEvent.type(
canvas.getByLabelText(/server url/i),
"https://api.githubcopilot.com/mcp/",
);
await expect(addButton).toBeEnabled();
await userEvent.click(
canvas.getByRole("button", { name: /authentication/i }),
);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
canvas.getByRole("combobox", { name: /authentication method/i }),
);
await userEvent.click(body.getByRole("option", { name: "OAuth2" }));
await expect(canvas.getByLabelText(/client id/i)).toBeInTheDocument();
await userEvent.click(addButton);
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
display_name: "GitHub",
slug: "github",
url: "https://api.githubcopilot.com/mcp/",
auth_type: "oauth2",
}),
);
});
},
};
@@ -0,0 +1,31 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { pageTitle } from "#/utils/page";
import { MCPServerForm } from "../components/MCPServerForm";
interface AddMCPServerPageViewProps {
isSaving: boolean;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
) => Promise<unknown>;
onCancel: () => void;
}
const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
isSaving,
onCreateServer,
onCancel,
}) => {
return (
<>
<title>{pageTitle("Add server", "AI Settings")}</title>
<MCPServerForm
isSaving={isSaving}
onCreateServer={onCreateServer}
onCancel={onCancel}
/>
</>
);
};
export default AddMCPServerPageView;
@@ -0,0 +1,28 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import { mcpServerConfigs } from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import MCPServersPageView from "./MCPServersPageView";
const MCPServersPage: FC = () => {
const { permissions } = useAuthenticated();
const serversQuery = useQuery(mcpServerConfigs());
const servers = (serversQuery.data ?? []).toSorted((a, b) =>
a.display_name.localeCompare(b.display_name),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<title>{pageTitle("MCP servers", "AI Settings")}</title>
<MCPServersPageView
isLoading={serversQuery.isLoading}
error={serversQuery.error}
servers={servers}
/>
</RequirePermission>
);
};
export default MCPServersPage;
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import MCPServersPageView from "./MCPServersPageView";
import {
MockCoderMCPServer,
MockGitHubMCPServer,
MockImageMCPServer,
MockMemoryMCPServer,
} from "./testFixtures";
const meta: Meta<typeof MCPServersPageView> = {
title: "pages/AISettingsPage/MCPServersPage/MCPServersPageView",
component: MCPServersPageView,
args: {
isLoading: false,
error: null,
servers: [
MockCoderMCPServer,
MockGitHubMCPServer,
MockImageMCPServer,
MockMemoryMCPServer,
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers" },
routing: [
{ path: "/ai/settings/mcp-servers", useStoryElement: true },
{ path: "/ai/settings/mcp-servers/add", useStoryElement: true },
{ path: "/ai/settings/mcp-servers/:serverId", useStoryElement: true },
],
}),
},
};
export default meta;
type Story = StoryObj<typeof MCPServersPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /add server/i }),
).toBeInTheDocument();
await expect(canvas.getByText("Coder")).toBeInTheDocument();
await expect(canvas.getByText("GitHub")).toBeInTheDocument();
await expect(canvas.getByText("Image")).toBeInTheDocument();
await expect(canvas.getByText("API key")).toBeInTheDocument();
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
},
};
export const Loading: Story = {
args: {
isLoading: true,
servers: [],
},
};
export const Empty: Story = {
args: {
servers: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("No MCP servers configured"),
).toBeInTheDocument();
},
};
export const LoadError: Story = {
args: {
error: new Error("Failed to load MCP servers"),
servers: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Failed to load MCP servers")).toBeVisible();
await expect(
canvas.queryByText("No MCP servers configured"),
).not.toBeInTheDocument();
},
};
@@ -0,0 +1,101 @@
import { PlusIcon } from "lucide-react";
import type { FC } from "react";
import { useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { MCPServerRow } from "./components/MCPServerRow";
interface MCPServersPageViewProps {
isLoading: boolean;
error: unknown;
servers: readonly TypesGen.MCPServerConfig[];
}
const MCPServersPageView: FC<MCPServersPageViewProps> = ({
isLoading,
error,
servers,
}) => {
const navigate = useNavigate();
const goToAddServer = () => void navigate("/ai/settings/mcp-servers/add");
return (
<div>
<SettingsHeader
actions={
<Button variant="outline" onClick={goToAddServer}>
<PlusIcon />
<span>Add server</span>
</Button>
}
>
<SettingsHeaderTitle>MCP servers</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure external MCP servers that provide additional tools for Coder
Agents.
</SettingsHeaderDescription>
</SettingsHeader>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
</div>
)}
<Table className="table-fixed" aria-label="MCP servers">
<TableHeader>
<TableRow>
<TableHead className="w-1/2">Name</TableHead>
<TableHead className="w-1/5">Auth Method</TableHead>
<TableHead className="w-1/5">Availability</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open server</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : !error && servers.length === 0 ? (
<TableEmpty
message="No MCP servers configured"
description="Add a server to give agents access to external tools."
cta={
<Button variant="outline" onClick={goToAddServer}>
<PlusIcon />
<span>Add server</span>
</Button>
}
/>
) : (
servers.map((server) => (
<MCPServerRow
key={server.id}
server={server}
onClick={() =>
void navigate(`/ai/settings/mcp-servers/${server.id}`)
}
/>
))
)}
</TableBody>
</Table>
</div>
);
};
export default MCPServersPageView;
@@ -0,0 +1,92 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Navigate, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import {
deleteMCPServerConfig,
mcpServerConfigs,
updateMCPServerConfig,
} from "#/api/queries/chats";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import UpdateMCPServerPageView from "./UpdateMCPServerPageView";
const UpdateMCPServerPage: FC = () => {
const { permissions } = useAuthenticated();
const { serverId } = useParams<{ serverId: string }>();
const queryClient = useQueryClient();
const navigate = useNavigate();
const serversQuery = useQuery(mcpServerConfigs());
const updateMutation = useMutation(updateMCPServerConfig(queryClient));
const deleteMutation = useMutation(deleteMCPServerConfig(queryClient));
const server = serversQuery.data?.find((item) => item.id === serverId);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
{!serverId ? (
<Navigate to="/ai/settings/mcp-servers" replace />
) : serversQuery.isLoading ? (
<>
<title>{pageTitle("Loading...", "AI Settings")}</title>
<Loader fullscreen />
</>
) : !server ? (
<Navigate to="/ai/settings/mcp-servers" replace />
) : (
<UpdateMCPServerPageView
server={server}
isSaving={updateMutation.isPending}
isDeleting={deleteMutation.isPending}
onCancel={() => void navigate("/ai/settings/mcp-servers")}
onUpdateServer={async (id, req) => {
try {
const updated = await updateMutation.mutateAsync({ id, req });
toast.success(`MCP server "${updated.display_name}" updated.`);
await navigate("/ai/settings/mcp-servers");
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to update MCP server."),
);
}
}}
onDeleteServer={async (id) => {
try {
await deleteMutation.mutateAsync(id);
toast.success(`MCP server "${server.display_name}" deleted.`);
await navigate("/ai/settings/mcp-servers", { replace: true });
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to delete MCP server."),
);
}
}}
onToggleEnabled={(enabled) => {
updateMutation.mutate(
{ id: server.id, req: { enabled } },
{
onSuccess: () => {
toast.success(
`MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`,
);
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to ${enabled ? "enable" : "disable"} MCP server.`,
),
);
},
},
);
}}
/>
)}
</RequirePermission>
);
};
export default UpdateMCPServerPage;
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { MockCoderMCPServer } from "../testFixtures";
import UpdateMCPServerPageView from "./UpdateMCPServerPageView";
const onUpdateServer = fn(
async (
_id: string,
req: TypesGen.UpdateMCPServerConfigRequest,
): Promise<unknown> => req,
);
const meta: Meta<typeof UpdateMCPServerPageView> = {
title: "pages/AISettingsPage/MCPServersPage/UpdateMCPServerPageView",
component: UpdateMCPServerPageView,
args: {
server: MockCoderMCPServer,
isSaving: false,
isDeleting: false,
onUpdateServer,
onDeleteServer: fn(async () => undefined),
onToggleEnabled: fn(),
onCancel: fn(),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers/mcp-coder" },
routing: { path: "/ai/settings/mcp-servers/:serverId" },
}),
},
};
export default meta;
type Story = StoryObj<typeof UpdateMCPServerPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder");
await userEvent.click(
canvas.getByRole("button", { name: /authentication/i }),
);
await expect(canvas.getByLabelText(/client secret/i)).toHaveValue(
"••••••••••••••••",
);
const updateButton = canvas.getByRole("button", { name: "Update server" });
await expect(updateButton).toBeEnabled();
await userEvent.click(updateButton);
await waitFor(() => {
expect(onUpdateServer).toHaveBeenCalledWith(
"mcp-coder",
expect.objectContaining({
display_name: "Coder",
slug: "coder",
}),
);
});
expect(onUpdateServer.mock.calls[0]?.[1]).not.toHaveProperty("enabled");
},
};
@@ -0,0 +1,45 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { pageTitle } from "#/utils/page";
import { MCPServerForm } from "../components/MCPServerForm";
interface UpdateMCPServerPageViewProps {
server: TypesGen.MCPServerConfig;
isSaving: boolean;
isDeleting: boolean;
onUpdateServer: (
serverId: string,
req: TypesGen.UpdateMCPServerConfigRequest,
) => Promise<unknown>;
onDeleteServer: (serverId: string) => Promise<void>;
onToggleEnabled: (enabled: boolean) => void;
onCancel: () => void;
}
const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
server,
isSaving,
isDeleting,
onUpdateServer,
onDeleteServer,
onToggleEnabled,
onCancel,
}) => {
return (
<>
<title>{pageTitle(server.display_name, "AI Settings")}</title>
<MCPServerForm
key={server.id}
server={server}
isSaving={isSaving}
isDeleting={isDeleting}
onUpdateServer={onUpdateServer}
onDeleteServer={onDeleteServer}
onToggleEnabled={onToggleEnabled}
onCancel={onCancel}
/>
</>
);
};
export default UpdateMCPServerPageView;
@@ -0,0 +1,94 @@
import { type FC, lazy, Suspense, useState } from "react";
import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Loader } from "#/components/Loader/Loader";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
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;
}
export const IconPickerField: FC<IconPickerFieldProps> = ({
id,
value,
placeholder,
disabled,
onChange,
onPickEmoji,
}) => {
const [open, setOpen] = useState(false);
const hasIcon = value !== "";
return (
<InputGroup>
<InputGroupInput
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="min-w-0 placeholder:text-content-disabled"
spellCheck={false}
/>
<InputGroupAddon align="inline-end" className="gap-1.5">
{hasIcon && (
<span className="flex size-5 items-center justify-center [&_img]:max-w-full [&_img]:object-contain">
<ExternalImage
alt=""
src={value}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
onLoad={(event) => {
event.currentTarget.style.display = "inline";
}}
/>
</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="subtle"
size="sm"
className="group h-7 gap-1"
disabled={disabled}
aria-label="Pick an emoji or icon"
>
Emoji
<AnimatedChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-min">
<Suspense fallback={<Loader />}>
<EmojiPicker
onEmojiSelect={(emoji) => {
const picked = emoji.src ?? `/emojis/${emoji.unified}.png`;
onPickEmoji(picked);
setOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
);
};
@@ -0,0 +1,300 @@
import type { FormikContextType } from "formik";
import { PlusIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Field } from "./MCPServerFormFieldPrimitives";
import {
AUTH_TYPE_OPTIONS,
type MCPServerFormValues,
SECRET_PLACEHOLDER,
} from "./mcpServerFormLogic";
interface MCPServerAuthSectionProps {
form: FormikContextType<MCPServerFormValues>;
formId: string;
disabled: boolean;
}
export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
form,
formId,
disabled,
}) => {
return (
<>
<Field
label="Authentication method"
htmlFor={`${formId}-auth`}
className="max-w-md"
>
<Select
value={form.values.authType}
onValueChange={(value) => void form.setFieldValue("authType", value)}
disabled={disabled}
>
<SelectTrigger id={`${formId}-auth`} className="shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{form.values.authType === "oauth2" && (
<OAuth2Fields form={form} formId={formId} disabled={disabled} />
)}
{form.values.authType === "api_key" && (
<APIKeyFields form={form} formId={formId} disabled={disabled} />
)}
{form.values.authType === "custom_headers" && (
<CustomHeadersFields form={form} formId={formId} disabled={disabled} />
)}
{form.values.authType === "user_oidc" && (
<p className="m-0 text-sm text-content-secondary">
Coder will forward the user's OIDC identity to this MCP server.
</p>
)}
</>
);
};
const OAuth2Fields: FC<MCPServerAuthSectionProps> = ({
form,
formId,
disabled,
}) => (
<div className="space-y-5">
<p className="m-0 text-sm text-content-secondary">
Register a client with the external MCP server's OAuth2 provider and enter
the credentials below. Coder will handle the per-user authorization flow.
</p>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field label="Client ID" htmlFor={`${formId}-oauth-id`}>
<Input
id={`${formId}-oauth-id`}
className="shadow-none"
{...form.getFieldProps("oauth2ClientID")}
disabled={disabled}
/>
</Field>
<Field label="Client secret" htmlFor={`${formId}-oauth-secret`}>
<SecretInput
id={`${formId}-oauth-secret`}
value={form.values.oauth2ClientSecret}
touched={form.values.oauth2SecretTouched}
onTouch={() => void form.setFieldValue("oauth2SecretTouched", true)}
onValueChange={(value) =>
void form.setFieldValue("oauth2ClientSecret", value)
}
onReset={() => void form.setFieldValue("oauth2SecretTouched", false)}
disabled={disabled}
/>
</Field>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field label="Authorization URL" htmlFor={`${formId}-oauth-auth-url`}>
<Input
id={`${formId}-oauth-auth-url`}
className="placeholder:text-content-disabled shadow-none"
{...form.getFieldProps("oauth2AuthURL")}
placeholder="https://"
disabled={disabled}
/>
</Field>
<Field label="Token URL" htmlFor={`${formId}-oauth-token-url`}>
<Input
id={`${formId}-oauth-token-url`}
className="placeholder:text-content-disabled shadow-none"
{...form.getFieldProps("oauth2TokenURL")}
disabled={disabled}
/>
</Field>
</div>
<Field label="Scopes" htmlFor={`${formId}-oauth-scopes`}>
<Input
id={`${formId}-oauth-scopes`}
className="shadow-none"
{...form.getFieldProps("oauth2Scopes")}
disabled={disabled}
/>
</Field>
</div>
);
const APIKeyFields: FC<MCPServerAuthSectionProps> = ({
form,
formId,
disabled,
}) => (
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field label="Header" htmlFor={`${formId}-api-header`}>
<Input
id={`${formId}-api-header`}
className="shadow-none"
{...form.getFieldProps("apiKeyHeader")}
placeholder="Authorization"
disabled={disabled}
/>
</Field>
<Field label="API key" htmlFor={`${formId}-api-key`}>
<SecretInput
id={`${formId}-api-key`}
value={form.values.apiKeyValue}
touched={form.values.apiKeyTouched}
onTouch={() => void form.setFieldValue("apiKeyTouched", true)}
onValueChange={(value) => void form.setFieldValue("apiKeyValue", value)}
onReset={() => void form.setFieldValue("apiKeyTouched", false)}
disabled={disabled}
/>
</Field>
</div>
);
const SecretInput: FC<{
id: string;
value: string;
touched: boolean;
onTouch: () => void;
onValueChange: (value: string) => void;
onReset: () => void;
disabled: boolean;
}> = ({ id, value, touched, onTouch, onValueChange, onReset, disabled }) => (
<Input
id={id}
className="font-mono shadow-none [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={value}
onChange={(event) => {
onTouch();
onValueChange(event.target.value);
}}
onFocus={() => {
if (!touched && value !== "") {
onValueChange("");
onTouch();
}
}}
onBlur={() => {
if (touched && value === "") {
onValueChange(SECRET_PLACEHOLDER);
onReset();
}
}}
disabled={disabled}
/>
);
const CustomHeadersFields: FC<MCPServerAuthSectionProps> = ({
form,
formId,
disabled,
}) => {
const headers =
form.values.customHeaders.length > 0
? form.values.customHeaders
: [{ key: "", value: "" }];
const setHeaders = (nextHeaders: Array<{ key: string; value: string }>) => {
void form.setFieldValue("customHeadersTouched", true);
void form.setFieldValue("customHeaders", nextHeaders);
};
return (
<div className="space-y-3">
<p className="m-0 text-sm text-content-secondary">
Enter custom headers to send with each request. Saving replaces existing
custom headers.
</p>
{headers.map((header, index) => (
<div
key={index.toString()}
className="grid items-end gap-3 sm:grid-cols-[1fr_1fr_auto]"
>
<CustomHeaderInput
formId={formId}
header={header}
index={index}
headers={headers}
setHeaders={setHeaders}
disabled={disabled}
/>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => setHeaders([...headers, { key: "", value: "" }])}
disabled={disabled}
>
<PlusIcon />
Add header
</Button>
</div>
);
};
const CustomHeaderInput: FC<{
formId: string;
header: { key: string; value: string };
index: number;
headers: Array<{ key: string; value: string }>;
setHeaders: (headers: Array<{ key: string; value: string }>) => void;
disabled: boolean;
}> = ({ formId, header, index, headers, setHeaders, disabled }) => (
<>
<Field label="Header name" htmlFor={`${formId}-custom-header-${index}`}>
<Input
id={`${formId}-custom-header-${index}`}
className="shadow-none"
value={header.key}
onChange={(event) => {
const nextHeaders = [...headers];
nextHeaders[index] = { ...header, key: event.target.value };
setHeaders(nextHeaders);
}}
disabled={disabled}
/>
</Field>
<Field label="Header value" htmlFor={`${formId}-custom-value-${index}`}>
<Input
id={`${formId}-custom-value-${index}`}
className="shadow-none"
value={header.value}
onChange={(event) => {
const nextHeaders = [...headers];
nextHeaders[index] = { ...header, value: event.target.value };
setHeaders(nextHeaders);
}}
disabled={disabled}
/>
</Field>
<Button
type="button"
variant="subtle"
size="icon"
aria-label="Remove header"
disabled={disabled || headers.length === 1}
onClick={() =>
setHeaders(headers.filter((_, headerIndex) => headerIndex !== index))
}
>
<XIcon />
</Button>
</>
);
@@ -0,0 +1,151 @@
import type { FormikContextType } from "formik";
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
import { Input } from "#/components/Input/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { Field } from "./MCPServerFormFieldPrimitives";
import {
AVAILABILITY_OPTIONS,
type MCPServerFormValues,
} from "./mcpServerFormLogic";
interface MCPServerBehaviorSectionProps {
form: FormikContextType<MCPServerFormValues>;
formId: string;
disabled: boolean;
}
export const MCPServerBehaviorSection: FC<MCPServerBehaviorSectionProps> = ({
form,
formId,
disabled,
}) => {
return (
<>
<Field
label="Availability"
htmlFor={`${formId}-availability`}
className="max-w-md"
description={
AVAILABILITY_OPTIONS.find(
(option) => option.value === form.values.availability,
)?.description
}
>
<Select
value={form.values.availability}
onValueChange={(value) =>
void form.setFieldValue("availability", value)
}
disabled={disabled}
>
<SelectTrigger id={`${formId}-availability`} className="shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABILITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<div className="flex flex-col gap-4">
<SwitchField
label="Model intent"
checked={form.values.modelIntent}
onCheckedChange={(checked) =>
void form.setFieldValue("modelIntent", checked)
}
disabled={disabled}
tooltip="Allows this server to be used for model-intent tools."
/>
<SwitchField
label="Allow all tools from this MCP server in root plan mode"
checked={form.values.allowInPlanMode}
onCheckedChange={(checked) =>
void form.setFieldValue("allowInPlanMode", checked)
}
disabled={disabled}
tooltip="Allows tools during planning. Workspace MCP and plan-mode controls still apply."
/>
<SwitchField
label="Forward Coder identity headers"
checked={form.values.forwardCoderHeaders}
onCheckedChange={(checked) =>
void form.setFieldValue("forwardCoderHeaders", checked)
}
disabled={disabled}
tooltip="Only enable for first-party or trusted MCP servers."
/>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field
label="Tool allow list"
htmlFor={`${formId}-allow-list`}
description="Comma-separated. Empty = all allowed."
>
<Input
id={`${formId}-allow-list`}
className="placeholder:text-content-disabled shadow-none"
{...form.getFieldProps("toolAllowList")}
placeholder="tool 1, tool 2"
disabled={disabled}
/>
</Field>
<Field
label="Tool deny list"
htmlFor={`${formId}-deny-list`}
description="Comma-separated names to block."
>
<Input
id={`${formId}-deny-list`}
className="placeholder:text-content-disabled shadow-none"
{...form.getFieldProps("toolDenyList")}
placeholder="tool 1, tool 2"
disabled={disabled}
/>
</Field>
</div>
</>
);
};
const SwitchField: FC<{
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
tooltip: string;
}> = ({ label, checked, onCheckedChange, disabled, tooltip }) => (
<div className="flex items-center gap-3">
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-label={label}
/>
<span className="text-sm text-content-primary">{label}</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px]">
{tooltip}
</TooltipContent>
</Tooltip>
</div>
);
@@ -0,0 +1,123 @@
import { useFormik } from "formik";
import { type FC, useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { MCPServerFormDialogs } from "./MCPServerFormDialogs";
import { MCPServerFormFields } from "./MCPServerFormFields";
import { MCPServerFormHeader } from "./MCPServerFormHeader";
import {
buildCreateMCPServerConfigRequest,
buildInitialMCPServerFormValues,
buildUpdateMCPServerConfigRequest,
canSubmitMCPServerForm,
type MCPServerFormValues,
} from "./mcpServerFormLogic";
type MCPServerFormCreateProps = {
server?: undefined;
isSaving: boolean;
isDeleting?: false;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
) => Promise<unknown>;
onUpdateServer?: undefined;
onDeleteServer?: undefined;
onToggleEnabled?: undefined;
onCancel: () => void;
};
type MCPServerFormEditProps = {
server: TypesGen.MCPServerConfig;
isSaving: boolean;
isDeleting: boolean;
onCreateServer?: undefined;
onUpdateServer: (
serverId: string,
req: TypesGen.UpdateMCPServerConfigRequest,
) => Promise<unknown>;
onDeleteServer?: (serverId: string) => Promise<void>;
onToggleEnabled?: (enabled: boolean) => void;
onCancel: () => void;
};
type MCPServerFormProps = MCPServerFormCreateProps | MCPServerFormEditProps;
export const MCPServerForm: FC<MCPServerFormProps> = ({
server,
isSaving,
isDeleting = false,
onCreateServer,
onUpdateServer,
onDeleteServer,
onToggleEnabled,
onCancel,
}) => {
const isEditing = server !== undefined;
const [showDetails, setShowDetails] = useState(false);
const [showAuth, setShowAuth] = useState(false);
const [showBehavior, setShowBehavior] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const form = useFormik<MCPServerFormValues>({
initialValues: buildInitialMCPServerFormValues(server),
onSubmit: async (values) => {
if (isSaving) return;
if (server && onUpdateServer) {
await onUpdateServer(
server.id,
buildUpdateMCPServerConfigRequest(values),
);
} else if (onCreateServer) {
await onCreateServer(buildCreateMCPServerConfigRequest(values));
}
},
});
const isDisabled = isSaving || isDeleting;
const canSubmit = canSubmitMCPServerForm(form.values, isDisabled);
const unsavedChanges = useUnsavedChangesPrompt(
form.dirty && !form.isSubmitting,
);
const title = isEditing
? form.values.displayName || "Edit server"
: "Add server";
return (
<>
<MCPServerFormHeader
server={server}
title={title}
iconUrl={form.values.iconURL}
isEditing={isEditing}
isDisabled={isDisabled}
onRequestDelete={() => setConfirmingDelete(true)}
onToggleEnabled={onToggleEnabled}
/>
<div className="flex flex-col gap-6 pt-6">
<MCPServerFormFields
form={form}
isSaving={isSaving}
isDisabled={isDisabled}
canSubmit={canSubmit}
isEditing={isEditing}
onCancel={onCancel}
showDetails={showDetails}
setShowDetails={setShowDetails}
showAuth={showAuth}
setShowAuth={setShowAuth}
showBehavior={showBehavior}
setShowBehavior={setShowBehavior}
/>
</div>
<MCPServerFormDialogs
server={server}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
onDeleteServer={onDeleteServer}
isDeleting={isDeleting}
unsavedChanges={unsavedChanges}
/>
</>
);
};
@@ -0,0 +1,56 @@
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import type { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { ConfirmDeleteDialog } from "#/pages/AgentsPage/components/ConfirmDeleteDialog";
interface MCPServerFormDialogsProps {
server?: TypesGen.MCPServerConfig;
confirmingDelete: boolean;
setConfirmingDelete: (open: boolean) => void;
onDeleteServer?: (serverId: string) => Promise<void>;
isDeleting: boolean;
unsavedChanges: ReturnType<typeof useUnsavedChangesPrompt>;
}
export const MCPServerFormDialogs: FC<MCPServerFormDialogsProps> = ({
server,
confirmingDelete,
setConfirmingDelete,
onDeleteServer,
isDeleting,
unsavedChanges,
}) => {
return (
<>
{server && onDeleteServer && (
<ConfirmDeleteDialog
open={confirmingDelete}
onOpenChange={setConfirmingDelete}
entity="MCP server"
description={`Delete "${server.display_name}"? Agents will no longer be able to use this server.`}
onConfirm={() => void onDeleteServer(server.id)}
isPending={isDeleting}
/>
)}
<ConfirmDialog
type="info"
hideCancel={false}
open={unsavedChanges.isOpen}
onClose={unsavedChanges.onCancel}
onConfirm={unsavedChanges.onConfirm}
title="Unsaved changes"
confirmText="Confirm"
description={
<div className="flex items-start gap-3">
<TriangleAlertIcon className="size-icon-sm mt-1 shrink-0" />
<p className="m-0">
Your updates haven't been saved. Leave anyway?
</p>
</div>
}
/>
</>
);
};
@@ -0,0 +1,81 @@
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/Collapsible/Collapsible";
import { Label } from "#/components/Label/Label";
import { cn } from "#/utils/cn";
const RequiredMark = () => (
<span className="text-xs font-bold text-content-destructive">*</span>
);
export const Field: FC<{
label: ReactNode;
htmlFor?: string;
required?: boolean;
children: ReactNode;
description?: ReactNode;
className?: string;
}> = ({ label, htmlFor, required, children, description, className }) => {
return (
<div className={cn("grid gap-1.5", className)}>
<Label
htmlFor={htmlFor}
className="flex items-center gap-1 leading-6 text-content-primary"
>
{label}
{required && <RequiredMark />}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
{children}
</div>
);
};
export const CollapsibleSection: FC<{
title: string;
description: string;
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
contentClassName?: string;
children: ReactNode;
}> = ({
title,
description,
open,
onOpenChange,
className,
contentClassName,
children,
}) => {
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className={cn("p-4", className)}
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-start gap-2 border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary">
{open ? (
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-primary" />
) : (
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-primary" />
)}
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
{title}
</h3>
<p className="m-0 text-sm text-content-secondary">{description}</p>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className={contentClassName}>{children}</div>
</CollapsibleContent>
</Collapsible>
);
};
@@ -0,0 +1,214 @@
import type { FormikContextType } from "formik";
import { type FC, useId } from "react";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { IconPickerField } from "./IconPickerField";
import { MCPServerAuthSection } from "./MCPServerAuthSection";
import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection";
import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives";
import {
type MCPServerFormValues,
slugify,
TRANSPORT_OPTIONS,
} from "./mcpServerFormLogic";
interface MCPServerFormFieldsProps {
form: FormikContextType<MCPServerFormValues>;
isSaving: boolean;
isDisabled: boolean;
canSubmit: boolean;
isEditing: boolean;
onCancel: () => void;
showDetails: boolean;
setShowDetails: (open: boolean) => void;
showAuth: boolean;
setShowAuth: (open: boolean) => void;
showBehavior: boolean;
setShowBehavior: (open: boolean) => void;
}
export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
form,
isSaving,
isDisabled,
canSubmit,
isEditing,
onCancel,
showDetails,
setShowDetails,
showAuth,
setShowAuth,
showBehavior,
setShowBehavior,
}) => {
const formId = useId();
return (
<div className="border border-solid p-6 rounded-lg">
<form
onSubmit={form.handleSubmit}
spellCheck={false}
autoComplete="off"
className="flex flex-col gap-6"
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field label="Slug" htmlFor={`${formId}-slug`} required>
<Input
id={`${formId}-slug`}
className="placeholder:text-content-disabled shadow-none"
value={form.values.slug}
onChange={(event) => {
void form.setFieldValue("slugTouched", true);
void form.setFieldValue("slug", event.target.value);
}}
placeholder="e.g. github, linear"
disabled={isDisabled}
/>
</Field>
<Field
label="Display name"
htmlFor={`${formId}-display-name`}
required
>
<Input
id={`${formId}-display-name`}
className="placeholder:text-content-disabled shadow-none"
value={form.values.displayName}
onChange={(event) => {
void form.setFieldValue("displayName", event.target.value);
if (!form.values.slugTouched) {
void form.setFieldValue("slug", slugify(event.target.value));
}
}}
disabled={isDisabled}
/>
</Field>
<div className="grid items-start gap-4 sm:col-span-2 sm:grid-cols-[1fr_224px]">
<Field label="Server URL" htmlFor={`${formId}-url`} required>
<InputGroup>
<InputGroupInput
id={`${formId}-url`}
className="placeholder:text-content-disabled"
{...form.getFieldProps("url")}
placeholder="https://"
disabled={isDisabled}
/>
</InputGroup>
</Field>
<Field label="Transport" htmlFor={`${formId}-transport`} required>
<Select
value={form.values.transport}
onValueChange={(value) =>
void form.setFieldValue("transport", value)
}
disabled={isDisabled}
>
<SelectTrigger
id={`${formId}-transport`}
className="shadow-none"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-solid border-border">
<CollapsibleSection
title="Details"
description="Optional description and icon shown to users."
open={showDetails}
onOpenChange={setShowDetails}
contentClassName="grid items-start gap-4 pt-5 pl-6 sm:grid-cols-2"
>
<Field label="Description" htmlFor={`${formId}-description`}>
<Input
id={`${formId}-description`}
className="placeholder:text-content-disabled shadow-none"
{...form.getFieldProps("description")}
disabled={isDisabled}
/>
</Field>
<Field label="Icon" htmlFor={`${formId}-icon`}>
<IconPickerField
id={`${formId}-icon`}
value={form.values.iconURL}
placeholder="file location"
onChange={(value) => void form.setFieldValue("iconURL", value)}
onPickEmoji={(value) =>
void form.setFieldValue("iconURL", value)
}
disabled={isDisabled}
/>
</Field>
</CollapsibleSection>
<CollapsibleSection
title="Authentication"
description="How users authenticate with this MCP server."
open={showAuth}
onOpenChange={setShowAuth}
className="border-0 border-t border-solid border-border"
contentClassName="space-y-5 pt-5 pl-6"
>
<MCPServerAuthSection
form={form}
formId={formId}
disabled={isDisabled}
/>
</CollapsibleSection>
<CollapsibleSection
title="Behavior"
description="Availability, model intent, identity headers, and tool governance."
open={showBehavior}
onOpenChange={setShowBehavior}
className="border-0 border-t border-solid border-border"
contentClassName="space-y-6 pt-5 pl-6"
>
<MCPServerBehaviorSection
form={form}
formId={formId}
disabled={isDisabled}
/>
</CollapsibleSection>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={isDisabled}
>
Cancel
</Button>
<Button disabled={!canSubmit} type="submit">
<Spinner loading={isSaving} />
{isEditing ? "Update server" : "Add server"}
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,126 @@
import { ArrowLeftIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { MCPServerIcon } from "./MCPServerIcon";
const MCPServerFormBackLink: FC = () => {
return (
<Link to="/ai/settings/mcp-servers" className="-ml-3">
<Button variant="subtle" type="button">
<ArrowLeftIcon />
<span>Back to MCP servers</span>
</Button>
</Link>
);
};
interface MCPServerFormHeaderProps {
server?: TypesGen.MCPServerConfig;
title: string;
iconUrl: string;
isEditing: boolean;
isDisabled: boolean;
onRequestDelete: () => void;
onToggleEnabled?: (enabled: boolean) => void;
}
export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
server,
title,
iconUrl,
isEditing,
isDisabled,
onRequestDelete,
onToggleEnabled,
}) => {
return (
<>
<div className="flex items-center justify-between">
<MCPServerFormBackLink />
{isEditing && server && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
type="button"
disabled={isDisabled}
aria-label="Server actions"
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRequestDelete}
>
<TrashIcon />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
{isEditing && (
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
)}
<SettingsHeaderTitle>
<span
className={cn(
"block min-w-0 truncate",
server?.enabled === false && "text-content-secondary",
)}
>
{title}
</span>
</SettingsHeaderTitle>
{isEditing && server && !server.enabled && (
<Badge variant="default">Disabled</Badge>
)}
</div>
{isEditing && server && (
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Switch
checked={server.enabled}
onCheckedChange={(checked) => onToggleEnabled?.(checked)}
disabled={isDisabled}
aria-label="Server enabled"
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{server.enabled
? "Disable this server. It will be hidden from agents."
: "Enable this server. It will be visible to agents."}
</TooltipContent>
</Tooltip>
<span className="text-sm">Enable</span>
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,29 @@
import { ServerIcon } from "lucide-react";
import type { FC } from "react";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { cn } from "#/utils/cn";
export const MCPServerIcon: FC<{
iconUrl: string;
name: string;
className?: string;
}> = ({ iconUrl, name, className }) => {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded bg-surface-secondary border border-solid border-border",
className,
)}
>
{iconUrl ? (
<ExternalImage
src={iconUrl}
alt={`${name} icon`}
className="size-3/5"
/>
) : (
<ServerIcon className="size-3/5 text-content-secondary" />
)}
</div>
);
};
@@ -0,0 +1,53 @@
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
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 { cn } from "#/utils/cn";
import { MCPServerIcon } from "./MCPServerIcon";
import { AUTH_TYPE_LABELS, AVAILABILITY_LABELS } from "./mcpServerFormLogic";
interface MCPServerRowProps {
server: TypesGen.MCPServerConfig;
onClick: () => void;
}
export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
const clickableProps = useClickableTableRow({ onClick });
const enabled = server.enabled;
return (
<TableRow {...clickableProps}>
<TableCell className="h-[72px] w-1/2">
<div className="flex min-w-0 items-center gap-3">
<MCPServerIcon
iconUrl={server.icon_url}
name={server.display_name}
className="size-10"
/>
<span
className={cn(
"truncate text-sm font-medium",
enabled ? "text-content-primary" : "text-content-secondary",
)}
>
{server.display_name}
</span>
</div>
</TableCell>
<TableCell className="w-1/5 text-sm">
{AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type}
</TableCell>
<TableCell className="w-1/5 text-sm">
{AVAILABILITY_LABELS[server.availability] ?? server.availability}
</TableCell>
<TableCell className="w-32">
<Badge variant="default">{enabled ? "Enabled" : "Disabled"}</Badge>
</TableCell>
<TableCell className="w-12">
<ChevronRightIcon className="size-5 text-content-primary" />
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { MockCoderMCPServer } from "../testFixtures";
import {
buildCreateMCPServerConfigRequest,
buildInitialMCPServerFormValues,
buildUpdateMCPServerConfigRequest,
canSubmitMCPServerForm,
type MCPServerFormValues,
SECRET_PLACEHOLDER,
} from "./mcpServerFormLogic";
const validValues = (
overrides: Partial<MCPServerFormValues> = {},
): MCPServerFormValues => ({
...buildInitialMCPServerFormValues(),
displayName: "GitHub",
slug: "github",
url: "https://api.githubcopilot.com/mcp/",
...overrides,
});
describe("mcpServerFormLogic", () => {
it("uses placeholders for existing secrets", () => {
const values = buildInitialMCPServerFormValues({
...MockCoderMCPServer,
has_api_key: true,
});
expect(values.oauth2ClientSecret).toBe(SECRET_PLACEHOLDER);
expect(values.apiKeyValue).toBe(SECRET_PLACEHOLDER);
});
it("requires display name, slug, and URL before submitting", () => {
expect(canSubmitMCPServerForm(validValues(), false)).toBe(true);
expect(
canSubmitMCPServerForm(validValues({ displayName: "" }), false),
).toBe(false);
expect(canSubmitMCPServerForm(validValues({ slug: "" }), false)).toBe(
false,
);
expect(canSubmitMCPServerForm(validValues({ url: "" }), false)).toBe(false);
expect(canSubmitMCPServerForm(validValues(), true)).toBe(false);
});
it("does not send placeholder OAuth2 secrets unless the value changes", () => {
const unchanged = buildCreateMCPServerConfigRequest(
validValues({
authType: "oauth2",
oauth2ClientSecret: SECRET_PLACEHOLDER,
oauth2SecretTouched: false,
}),
);
const changed = buildCreateMCPServerConfigRequest(
validValues({
authType: "oauth2",
oauth2ClientSecret: "new-secret",
oauth2SecretTouched: true,
}),
);
expect(unchanged.oauth2_client_secret).toBeUndefined();
expect(changed.oauth2_client_secret).toBe("new-secret");
});
it("does not send placeholder API key values unless the value changes", () => {
const unchanged = buildCreateMCPServerConfigRequest(
validValues({
authType: "api_key",
apiKeyValue: SECRET_PLACEHOLDER,
apiKeyTouched: false,
}),
);
const changed = buildCreateMCPServerConfigRequest(
validValues({
authType: "api_key",
apiKeyValue: "new-key",
apiKeyTouched: true,
}),
);
expect(unchanged.api_key_value).toBeUndefined();
expect(changed.api_key_value).toBe("new-key");
});
it("omits enabled from update requests", () => {
const request = buildUpdateMCPServerConfigRequest(
validValues({ enabled: false }),
);
expect(request.enabled).toBeUndefined();
});
it("initializes slugTouched true for edit and false for create", () => {
const createValues = buildInitialMCPServerFormValues();
expect(createValues.slugTouched).toBe(false);
const editValues = buildInitialMCPServerFormValues(MockCoderMCPServer);
expect(editValues.slugTouched).toBe(true);
});
it("only sends touched custom headers with non-empty keys", () => {
const untouched = buildCreateMCPServerConfigRequest(
validValues({
authType: "custom_headers",
customHeadersTouched: false,
customHeaders: [{ key: "X-Test", value: "secret" }],
}),
);
const touched = buildCreateMCPServerConfigRequest(
validValues({
authType: "custom_headers",
customHeadersTouched: true,
customHeaders: [
{ key: "X-Test", value: "secret" },
{ key: " ", value: "ignored" },
],
}),
);
expect(untouched.custom_headers).toBeUndefined();
expect(touched.custom_headers).toEqual({ "X-Test": "secret" });
});
});
@@ -0,0 +1,208 @@
import type * as TypesGen from "#/api/typesGenerated";
export const SECRET_PLACEHOLDER = "••••••••••••••••";
export const TRANSPORT_OPTIONS = [
{ value: "streamable_http", label: "Streamable HTTP" },
{ value: "sse", label: "SSE" },
] as const;
export const AUTH_TYPE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "oauth2", label: "OAuth2" },
{ value: "api_key", label: "API key" },
{ value: "custom_headers", label: "Custom headers" },
{ value: "user_oidc", label: "User OIDC identity" },
] as const;
export const AUTH_TYPE_LABELS = Object.fromEntries(
AUTH_TYPE_OPTIONS.map(({ value, label }) => [value, label]),
) as Record<string, string>;
export const AVAILABILITY_OPTIONS = [
{
value: "force_on",
label: "Force on",
description: "Always injected into every conversation.",
},
{
value: "default_on",
label: "Default on",
description: "Pre-selected but users can opt out.",
},
{
value: "default_off",
label: "Default off",
description: "Available but users must opt in.",
},
] as const;
export const AVAILABILITY_LABELS = Object.fromEntries(
AVAILABILITY_OPTIONS.map(({ value, label }) => [value, label]),
) as Record<string, string>;
export interface MCPServerFormValues {
displayName: string;
slug: string;
slugTouched: boolean;
description: string;
iconURL: string;
url: string;
transport: string;
authType: string;
oauth2ClientID: string;
oauth2ClientSecret: string;
oauth2SecretTouched: boolean;
oauth2AuthURL: string;
oauth2TokenURL: string;
oauth2Scopes: string;
apiKeyHeader: string;
apiKeyValue: string;
apiKeyTouched: boolean;
availability: string;
enabled: boolean;
modelIntent: boolean;
allowInPlanMode: boolean;
forwardCoderHeaders: boolean;
toolAllowList: string;
toolDenyList: string;
customHeaders: Array<{ key: string; value: string }>;
customHeadersTouched: boolean;
}
export const slugify = (value: string): string =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
export const buildInitialMCPServerFormValues = (
server?: TypesGen.MCPServerConfig,
): MCPServerFormValues => ({
displayName: server?.display_name ?? "",
slug: server?.slug ?? "",
slugTouched: Boolean(server),
description: server?.description ?? "",
iconURL: server?.icon_url ?? "",
url: server?.url ?? "",
transport: server?.transport ?? "streamable_http",
authType: server?.auth_type ?? "none",
oauth2ClientID: server?.oauth2_client_id ?? "",
oauth2ClientSecret: server?.has_oauth2_secret ? SECRET_PLACEHOLDER : "",
oauth2SecretTouched: false,
oauth2AuthURL: server?.oauth2_auth_url ?? "",
oauth2TokenURL: server?.oauth2_token_url ?? "",
oauth2Scopes: server?.oauth2_scopes ?? "",
apiKeyHeader: server?.api_key_header ?? "",
apiKeyValue: server?.has_api_key ? SECRET_PLACEHOLDER : "",
apiKeyTouched: false,
availability: server?.availability ?? "default_off",
enabled: server?.enabled ?? true,
modelIntent: server?.model_intent ?? false,
allowInPlanMode: server?.allow_in_plan_mode ?? false,
forwardCoderHeaders: server?.forward_coder_headers ?? false,
toolAllowList: server?.tool_allow_list.join(", ") ?? "",
toolDenyList: server?.tool_deny_list.join(", ") ?? "",
customHeaders: [],
customHeadersTouched: false,
});
export const canSubmitMCPServerForm = (
values: MCPServerFormValues,
isDisabled: boolean,
): boolean =>
!isDisabled &&
values.displayName.trim() !== "" &&
values.slug.trim() !== "" &&
values.url.trim() !== "";
export const buildCreateMCPServerConfigRequest = (
values: MCPServerFormValues,
): TypesGen.CreateMCPServerConfigRequest => {
const toolAllowList = values.toolAllowList
.split(",")
.map((tool) => tool.trim())
.filter(Boolean);
const toolDenyList = values.toolDenyList
.split(",")
.map((tool) => tool.trim())
.filter(Boolean);
const request: TypesGen.CreateMCPServerConfigRequest = {
display_name: values.displayName.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
icon_url: values.iconURL.trim(),
url: values.url.trim(),
transport: values.transport,
auth_type: values.authType,
availability: values.availability,
enabled: values.enabled,
model_intent: values.modelIntent,
allow_in_plan_mode: values.allowInPlanMode,
forward_coder_headers: values.forwardCoderHeaders,
tool_allow_list: toolAllowList,
tool_deny_list: toolDenyList,
};
if (values.authType === "oauth2") {
const oauth2ClientSecret =
values.oauth2SecretTouched &&
values.oauth2ClientSecret !== SECRET_PLACEHOLDER &&
values.oauth2ClientSecret !== ""
? values.oauth2ClientSecret
: undefined;
return {
...request,
oauth2_client_id: values.oauth2ClientID.trim(),
oauth2_client_secret: oauth2ClientSecret,
oauth2_auth_url: values.oauth2AuthURL.trim() || undefined,
oauth2_token_url: values.oauth2TokenURL.trim() || undefined,
oauth2_scopes: values.oauth2Scopes.trim() || undefined,
};
}
if (values.authType === "api_key") {
const apiKeyValue =
values.apiKeyTouched &&
values.apiKeyValue !== SECRET_PLACEHOLDER &&
values.apiKeyValue !== ""
? values.apiKeyValue
: undefined;
return {
...request,
api_key_header: values.apiKeyHeader.trim() || undefined,
api_key_value: apiKeyValue,
};
}
if (values.authType === "custom_headers" && values.customHeadersTouched) {
return {
...request,
custom_headers: Object.fromEntries(
values.customHeaders
.map(({ key, value }) => [key.trim(), value] as const)
.filter(([key]) => key !== ""),
),
};
}
return request;
};
export const buildUpdateMCPServerConfigRequest = (
values: MCPServerFormValues,
): TypesGen.UpdateMCPServerConfigRequest => {
const base = buildCreateMCPServerConfigRequest(values);
// The edit-page header toggle owns `enabled`; the form's copy is stale
// relative to the toggle, so omit it from the update payload.
const { enabled: _enabled, ...updateFields } = base;
return {
...updateFields,
tool_allow_list: [...(base.tool_allow_list ?? [])],
tool_deny_list: [...(base.tool_deny_list ?? [])],
};
};
@@ -0,0 +1,63 @@
import type * as TypesGen from "#/api/typesGenerated";
import { MockMCPServerConfig as BaseMockMCPServerConfig } from "#/testHelpers/chatEntities";
const now = "2026-03-19T12:00:00.000Z";
const MockMCPServerConfig: TypesGen.MCPServerConfig = {
...BaseMockMCPServerConfig,
created_at: now,
updated_at: now,
};
export const MockCoderMCPServer: TypesGen.MCPServerConfig = {
...MockMCPServerConfig,
id: "mcp-coder",
display_name: "Coder",
slug: "coder",
icon_url: "/icon/coder.svg",
url: "https://dev.coder.com/api/experimental/mcp/http",
transport: "streamable_http",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "default_off",
enabled: true,
};
export const MockGitHubMCPServer: TypesGen.MCPServerConfig = {
...MockMCPServerConfig,
id: "mcp-github",
display_name: "GitHub",
slug: "github",
icon_url: "/icon/github.svg",
url: "https://api.githubcopilot.com/mcp/",
transport: "streamable_http",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "default_off",
enabled: true,
};
export const MockImageMCPServer: TypesGen.MCPServerConfig = {
...MockMCPServerConfig,
id: "mcp-image",
display_name: "Image",
slug: "image",
url: "https://mcp.example.com/image",
transport: "streamable_http",
auth_type: "api_key",
has_api_key: true,
availability: "default_off",
enabled: false,
};
export const MockMemoryMCPServer: TypesGen.MCPServerConfig = {
...MockMCPServerConfig,
id: "mcp-memory",
display_name: "Memory",
slug: "memory",
url: "https://mcp.example.com/memory",
transport: "streamable_http",
auth_type: "oauth2",
availability: "force_on",
enabled: true,
};
@@ -1,45 +0,0 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
createMCPServerConfig,
deleteMCPServerConfig,
mcpServerConfigs,
updateMCPServerConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
const AgentSettingsMCPServersPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const serversQuery = useQuery(mcpServerConfigs());
const createServerMutation = useMutation(createMCPServerConfig(queryClient));
const updateServerMutation = useMutation(updateMCPServerConfig(queryClient));
const deleteServerMutation = useMutation(deleteMCPServerConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<MCPServerAdminPanel
sectionLabel="MCP servers"
sectionDescription="Configure external MCP servers that provide additional tools for Coder Agents."
serversData={serversQuery.data}
isLoadingServers={serversQuery.isLoading}
serversError={serversQuery.isError ? serversQuery.error : null}
onCreateServer={(req) => createServerMutation.mutateAsync(req)}
onUpdateServer={(args) => updateServerMutation.mutateAsync(args)}
onDeleteServer={(id) => deleteServerMutation.mutateAsync(id)}
isCreatingServer={createServerMutation.isPending}
isUpdatingServer={updateServerMutation.isPending}
isDeletingServer={deleteServerMutation.isPending}
createError={createServerMutation.error}
updateError={updateServerMutation.error}
deleteError={deleteServerMutation.error}
/>
</RequirePermission>
);
};
export default AgentSettingsMCPServersPage;
@@ -1,35 +0,0 @@
import type { FC, ReactNode } from "react";
interface ProviderFieldProps {
label: string;
htmlFor?: string;
required?: boolean;
description?: string;
children: ReactNode;
}
export const ProviderField: FC<ProviderFieldProps> = ({
label,
htmlFor,
required,
description,
children,
}) => (
<div className="grid gap-1.5">
<div className="flex items-baseline gap-1.5">
<label
htmlFor={htmlFor}
className="text-sm font-medium text-content-primary"
>
{label}
</label>
{required && (
<span className="text-xs font-bold text-content-destructive">*</span>
)}
</div>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
{children}
</div>
);
@@ -180,9 +180,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={ServerIcon}
label="MCP servers"
active={settingsSection === "mcp-servers"}
to="/agents/settings/mcp-servers"
state={location.state}
active={false}
to="/ai/settings/mcp-servers"
trailingIcon={ArrowUpRightIcon}
/>
<SettingsNavItem
icon={LayoutTemplateIcon}
@@ -1,732 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
import { MCPServerAdminPanel } from "./MCPServerAdminPanel";
// ── Helpers ────────────────────────────────────────────────────
const now = "2026-03-19T12:00:00.000Z";
const createServerConfig = (
overrides: Partial<TypesGen.MCPServerConfig> &
Pick<TypesGen.MCPServerConfig, "id" | "display_name" | "slug">,
): TypesGen.MCPServerConfig => ({
...MockMCPServerConfig,
created_at: now,
updated_at: now,
...overrides,
});
// ── Meta ───────────────────────────────────────────────────────
const meta: Meta<typeof MCPServerAdminPanel> = {
title: "pages/AgentsPage/MCPServerAdminPanel",
component: MCPServerAdminPanel,
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents/settings/mcp-servers" },
routing: { path: "/agents/settings/mcp-servers" },
}),
},
args: {
serversData: [],
isLoadingServers: false,
serversError: null,
onCreateServer: fn(async () => ({}) as TypesGen.MCPServerConfig),
onUpdateServer: fn(async () => ({}) as TypesGen.MCPServerConfig),
onDeleteServer: fn(async () => undefined),
isCreatingServer: false,
isUpdatingServer: false,
isDeletingServer: false,
createError: null,
updateError: null,
deleteError: null,
},
};
export default meta;
type Story = StoryObj<typeof MCPServerAdminPanel>;
// ── Stories ────────────────────────────────────────────────────
/** Empty state with no servers configured. */
export const EmptyState: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByText(/No MCP servers configured yet/i),
).toBeInTheDocument();
// 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();
},
};
/** List view with multiple servers showing status indicators. */
export const ServerList: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
icon_url: "/icon/widgets.svg",
url: "https://mcp.sentry.io/sse",
transport: "sse",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "force_on",
enabled: true,
}),
createServerConfig({
id: "mcp-linear",
display_name: "Linear",
slug: "linear",
url: "https://mcp.linear.app/v1",
transport: "streamable_http",
auth_type: "api_key",
has_api_key: true,
availability: "default_on",
enabled: true,
}),
createServerConfig({
id: "mcp-github",
display_name: "GitHub",
slug: "github",
icon_url: "/icon/github.svg",
url: "https://api.githubcopilot.com/mcp/",
transport: "streamable_http",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "default_off",
enabled: false,
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
// All three servers should be visible.
await expect(
await body.findByRole("button", { name: /Sentry/ }),
).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();
},
};
/** Navigate to the create form and fill it out. */
export const CreateServer: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
// Click Add Server.
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
// Fill in the Display name field.
const nameInput = await body.findByLabelText(/Display Name/i);
await userEvent.type(nameInput, "Sentry");
// Required fields (display name, slug, server URL) are always visible.
// Optional sections start collapsed and the Enabled switch is edit-only.
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();
// Slug should auto-populate from the display name.
await expect(body.getByLabelText(/^Slug/i)).toHaveValue("sentry");
await userEvent.type(
body.getByLabelText(/Server URL/i),
"https://mcp.sentry.io/sse",
);
// Submit.
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
display_name: "Sentry",
slug: "sentry",
url: "https://mcp.sentry.io/sse",
transport: "streamable_http",
auth_type: "none",
}),
);
},
};
/** Open the create form and select OAuth2 auth type. */
export const CreateServerOAuth2: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(await body.findByLabelText(/Display Name/i), "GitHub");
await userEvent.type(
body.getByLabelText(/Server URL/i),
"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 }));
// OAuth2 fields should appear.
await expect(await body.findByLabelText(/Client ID/i)).toBeInTheDocument();
expect(body.getByLabelText(/Client Secret/i)).toBeInTheDocument();
expect(body.getByLabelText(/Authorization URL/i)).toBeInTheDocument();
expect(body.getByLabelText(/Token URL/i)).toBeInTheDocument();
expect(body.getByLabelText(/^Scopes/i)).toBeInTheDocument();
// Fill OAuth2 fields.
await userEvent.type(body.getByLabelText(/Client ID/i), "my-client-id");
await userEvent.type(body.getByLabelText(/Client Secret/i), "my-secret");
// Submit.
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "oauth2",
oauth2_client_id: "my-client-id",
oauth2_client_secret: "my-secret",
}),
);
},
};
/** Open the create form and select API Key auth type. */
export const CreateServerAPIKey: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(await body.findByLabelText(/Display Name/i), "Linear");
await userEvent.type(
body.getByLabelText(/Server URL/i),
"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(
await body.findByRole("option", { name: /API Key/i }),
);
// API key fields should appear.
await expect(
await body.findByLabelText(/Header Name/i),
).toBeInTheDocument();
expect(body.getByLabelText(/API Key/i)).toBeInTheDocument();
await userEvent.type(body.getByLabelText(/Header Name/i), "Authorization");
await userEvent.type(body.getByLabelText(/API Key/i), "lin_api_12345");
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "api_key",
api_key_header: "Authorization",
api_key_value: "lin_api_12345",
}),
);
},
};
/** Click an existing server to open the edit form. */
export const EditServer: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
description: "Error tracking",
url: "https://mcp.sentry.io/sse",
transport: "sse",
auth_type: "none",
availability: "default_on",
enabled: true,
}),
],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
// Click the server row.
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
// 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);
await userEvent.type(descField, "Sentry error tracking integration");
await userEvent.click(body.getByRole("button", { name: /Save changes/i }));
await waitFor(() => {
expect(args.onUpdateServer).toHaveBeenCalledTimes(1);
});
expect(args.onUpdateServer).toHaveBeenCalledWith({
id: "mcp-sentry",
req: expect.objectContaining({
description: "Sentry error tracking integration",
}),
});
},
};
/** Edit a server that has OAuth2 — secret field should show placeholder. */
export const EditServerWithOAuth2Secret: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-github",
display_name: "GitHub",
slug: "github",
url: "https://api.githubcopilot.com/mcp/",
auth_type: "oauth2",
oauth2_client_id: "gh-client-id",
has_oauth2_secret: true,
oauth2_auth_url: "https://github.com/login/oauth/authorize",
oauth2_token_url: "https://github.com/login/oauth/access_token",
oauth2_scopes: "repo user",
availability: "default_on",
enabled: true,
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
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("••••••••••••••••");
expect(body.getByLabelText(/Client ID/i)).toHaveValue("gh-client-id");
},
};
/** Edit a server that has custom headers configured. */
export const EditServerWithCustomHeaders: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-custom",
display_name: "Custom API",
slug: "custom-api",
url: "https://mcp.example.com/v1",
auth_type: "custom_headers",
has_custom_headers: true,
availability: "default_on",
enabled: true,
}),
],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
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),
).toBeInTheDocument();
// Add a new header.
await userEvent.click(body.getByRole("button", { name: /Add header/i }));
await userEvent.type(
body.getByLabelText(/Header 1 name/i),
"Authorization",
);
await userEvent.type(
body.getByLabelText(/Header 1 value/i),
"Bearer tok_abc",
);
// Submit.
await userEvent.click(body.getByRole("button", { name: /Save changes/i }));
await waitFor(() => {
expect(args.onUpdateServer).toHaveBeenCalledTimes(1);
});
expect(args.onUpdateServer).toHaveBeenCalledWith({
id: "mcp-custom",
req: expect.objectContaining({
custom_headers: { Authorization: "Bearer tok_abc" },
}),
});
},
};
/** Delete a server shows confirmation dialog. */
export const DeleteServerConfirmation: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
// Click Delete.
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
// Confirmation dialog should appear.
await expect(
await body.findByText(/Are you sure you want to delete this MCP server/i),
).toBeInTheDocument();
await expect(body.getByRole("dialog")).toBeInTheDocument();
},
};
/** Cancel delete closes the dialog. */
export const DeleteServerCancelled: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
await body.findByText(/Are you sure you want to delete this MCP server/i);
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
// The dialog should be closed and the form footer restored.
await waitFor(() => {
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
});
await expect(
body.findByRole("button", { name: "Delete" }),
).resolves.toBeInTheDocument();
expect(
body.getByRole("button", { name: /Save changes/i }),
).toBeInTheDocument();
},
};
/** 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: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
}),
],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
await body.findByText(/Are you sure you want to delete this MCP server/i);
await userEvent.click(
body.getByRole("button", { name: /Delete MCP server/i }),
);
await waitFor(() => {
expect(args.onDeleteServer).toHaveBeenCalledTimes(1);
});
expect(args.onDeleteServer).toHaveBeenCalledWith("mcp-sentry");
},
};
/** Navigate to form and back without saving. */
export const BackToList: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
}),
],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /^Add server$/i }),
);
// Click Back.
await userEvent.click(await body.findByText("Back"));
// Should be back on the list.
await expect(
await body.findByRole("button", { name: /Sentry/ }),
).toBeInTheDocument();
expect(args.onCreateServer).not.toHaveBeenCalled();
},
};
/** Create a server with tool allow/deny lists. */
export const CreateServerWithToolGovernance: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(
await body.findByLabelText(/Display Name/i),
"Restricted Server",
);
await userEvent.type(
body.getByLabelText(/Server URL/i),
"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",
);
await userEvent.type(
body.getByLabelText(/Tool Deny List/i),
"delete_file, execute",
);
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
tool_allow_list: ["search", "read_file"],
tool_deny_list: ["delete_file", "execute"],
}),
);
},
};
/** Selecting Custom Headers auth type and adding a header via the key-value editor. */
export const CustomHeadersAuthType: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(
await body.findByLabelText(/Display Name/i),
"Custom API",
);
await userEvent.type(
body.getByLabelText(/Server URL/i),
"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(
await body.findByRole("option", { name: /Custom Headers/i }),
);
// Add a header.
await userEvent.click(
await body.findByRole("button", { name: /Add header/i }),
);
await userEvent.type(body.getByLabelText(/Header 1 name/i), "X-Api-Token");
await userEvent.type(
body.getByLabelText(/Header 1 value/i),
"secret-token-123",
);
// Submit.
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "custom_headers",
custom_headers: { "X-Api-Token": "secret-token-123" },
}),
);
},
};
export const CreateServerUserOIDC: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(
await body.findByLabelText(/Display Name/i),
"Internal API",
);
await userEvent.type(
body.getByLabelText(/Server URL/i),
"https://mcp.internal.example.com/v1",
);
await userEvent.click(
await body.findByRole("button", { name: /Authentication/i }),
);
await userEvent.click(body.getByLabelText(/Authentication/i));
await userEvent.click(
await body.findByRole("option", { name: /User OIDC Identity/i }),
);
// No additional auth fields for user_oidc; the helper text is shown.
expect(
body.getByText(/forwarded to this MCP server in the/i),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "user_oidc",
}),
);
// Should not include any oauth2/api_key/custom_headers fields.
const call = (args.onCreateServer as ReturnType<typeof fn>).mock
.calls[0][0];
expect(call).not.toHaveProperty("oauth2_client_id");
expect(call).not.toHaveProperty("api_key_value");
expect(call).not.toHaveProperty("custom_headers");
},
};
File diff suppressed because it is too large Load Diff
+29 -4
View File
@@ -390,9 +390,6 @@ const AgentSettingsPersonalSkillsPage = lazy(
const AgentSettingsAPIKeysPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"),
);
const AgentSettingsMCPServersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsMCPServersPage"),
);
const AgentSettingsSpendPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsSpendPage"),
);
@@ -459,6 +456,21 @@ const AISettingsUpdateModelPage = lazy(
() =>
import("./pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage"),
);
const AISettingsMCPServersPage = lazy(
() => import("./pages/AISettingsPage/MCPServersPage/MCPServersPage"),
);
const AISettingsAddMCPServerPage = lazy(
() =>
import(
"./pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage"
),
);
const AISettingsUpdateMCPServerPage = lazy(
() =>
import(
"./pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage"
),
);
const AISettingsIndexPage = () => {
const { permissions } = useAuthenticated();
@@ -471,6 +483,10 @@ const AISettingsIndexPage = () => {
return <Navigate to="/ai/settings/gateway-keys" replace />;
}
if (permissions.editDeploymentConfig) {
return <Navigate to="/ai/settings/models" replace />;
}
return <AISettingsProvidersPage />;
};
@@ -767,6 +783,15 @@ export const router = createBrowserRouter(
path="models/:modelId"
element={<AISettingsUpdateModelPage />}
/>
<Route path="mcp-servers" element={<AISettingsMCPServersPage />} />
<Route
path="mcp-servers/add"
element={<AISettingsAddMCPServerPage />}
/>
<Route
path="mcp-servers/:serverId"
element={<AISettingsUpdateMCPServerPage />}
/>
<Route path="add" element={<AISettingsAddProviderPage />} />
<Route
path=":providerId"
@@ -865,7 +890,7 @@ export const router = createBrowserRouter(
/>
<Route
path="mcp-servers"
element={<AgentSettingsMCPServersPage />}
element={<Navigate to="/ai/settings/mcp-servers" replace />}
/>
<Route path="spend" element={<AgentSettingsSpendPage />} />
<Route path="limits" element={<Navigate to="spend" replace />} />