diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index 6cd58ceb46..70846ce315 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -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**. diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index e5aefebe62..ea98ebf2b2 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -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(); diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 9ebccff0bf..c20005ff4f 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -50,6 +50,11 @@ const AISettingsSidebarView: FC = ({ Templates )} + {permissions.editDeploymentConfig && ( + + MCP servers + + )} {permissions.editDeploymentConfig && (
diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx new file mode 100644 index 0000000000..60f4988510 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -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 ( + + 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.")); + } + }} + /> + + ); +}; + +export default AddMCPServerPage; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx new file mode 100644 index 0000000000..8394af8408 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -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 = { + 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; + +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", + }), + ); + }); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx new file mode 100644 index 0000000000..bb3046e200 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -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; + onCancel: () => void; +} + +const AddMCPServerPageView: FC = ({ + isSaving, + onCreateServer, + onCancel, +}) => { + return ( + <> + {pageTitle("Add server", "AI Settings")} + + + ); +}; + +export default AddMCPServerPageView; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx new file mode 100644 index 0000000000..2d070c6e29 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -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 ( + + {pageTitle("MCP servers", "AI Settings")} + + + ); +}; + +export default MCPServersPage; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx new file mode 100644 index 0000000000..accb260840 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx @@ -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 = { + 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; + +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(); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx new file mode 100644 index 0000000000..7bd91a2fe2 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -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 = ({ + isLoading, + error, + servers, +}) => { + const navigate = useNavigate(); + const goToAddServer = () => void navigate("/ai/settings/mcp-servers/add"); + + return ( +
+ + + Add server + + } + > + MCP servers + + Configure external MCP servers that provide additional tools for Coder + Agents. + + + {Boolean(error) && ( +
+ +
+ )} + + + + Name + Auth Method + Availability + Status + + Open server + + + + + {isLoading ? ( + + ) : !error && servers.length === 0 ? ( + + + Add server + + } + /> + ) : ( + servers.map((server) => ( + + void navigate(`/ai/settings/mcp-servers/${server.id}`) + } + /> + )) + )} + +
+
+ ); +}; + +export default MCPServersPageView; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx new file mode 100644 index 0000000000..d63232c167 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -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 ( + + {!serverId ? ( + + ) : serversQuery.isLoading ? ( + <> + {pageTitle("Loading...", "AI Settings")} + + + ) : !server ? ( + + ) : ( + 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.`, + ), + ); + }, + }, + ); + }} + /> + )} + + ); +}; + +export default UpdateMCPServerPage; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx new file mode 100644 index 0000000000..5ad2d5d7d3 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -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 => req, +); + +const meta: Meta = { + 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; + +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"); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx new file mode 100644 index 0000000000..83373b6395 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -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; + onDeleteServer: (serverId: string) => Promise; + onToggleEnabled: (enabled: boolean) => void; + onCancel: () => void; +} + +const UpdateMCPServerPageView: FC = ({ + server, + isSaving, + isDeleting, + onUpdateServer, + onDeleteServer, + onToggleEnabled, + onCancel, +}) => { + return ( + <> + {pageTitle(server.display_name, "AI Settings")} + + + ); +}; + +export default UpdateMCPServerPageView; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx new file mode 100644 index 0000000000..74ea4f2b6d --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx @@ -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 = ({ + id, + value, + placeholder, + disabled, + onChange, + onPickEmoji, +}) => { + const [open, setOpen] = useState(false); + const hasIcon = value !== ""; + + return ( + + onChange(event.target.value)} + placeholder={placeholder} + disabled={disabled} + className="min-w-0 placeholder:text-content-disabled" + spellCheck={false} + /> + + {hasIcon && ( + + { + event.currentTarget.style.display = "none"; + }} + onLoad={(event) => { + event.currentTarget.style.display = "inline"; + }} + /> + + )} + + + + + + }> + { + const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; + onPickEmoji(picked); + setOpen(false); + }} + /> + + + + + + ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerAuthSection.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerAuthSection.tsx new file mode 100644 index 0000000000..a187b4e035 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerAuthSection.tsx @@ -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; + formId: string; + disabled: boolean; +} + +export const MCPServerAuthSection: FC = ({ + form, + formId, + disabled, +}) => { + return ( + <> + + + + {form.values.authType === "oauth2" && ( + + )} + {form.values.authType === "api_key" && ( + + )} + {form.values.authType === "custom_headers" && ( + + )} + {form.values.authType === "user_oidc" && ( +

+ Coder will forward the user's OIDC identity to this MCP server. +

+ )} + + ); +}; + +const OAuth2Fields: FC = ({ + form, + formId, + disabled, +}) => ( +
+

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

+
+ + + + + void form.setFieldValue("oauth2SecretTouched", true)} + onValueChange={(value) => + void form.setFieldValue("oauth2ClientSecret", value) + } + onReset={() => void form.setFieldValue("oauth2SecretTouched", false)} + disabled={disabled} + /> + +
+
+ + + + + + +
+ + + +
+); + +const APIKeyFields: FC = ({ + form, + formId, + disabled, +}) => ( +
+ + + + + void form.setFieldValue("apiKeyTouched", true)} + onValueChange={(value) => void form.setFieldValue("apiKeyValue", value)} + onReset={() => void form.setFieldValue("apiKeyTouched", false)} + disabled={disabled} + /> + +
+); + +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 }) => ( + { + onTouch(); + onValueChange(event.target.value); + }} + onFocus={() => { + if (!touched && value !== "") { + onValueChange(""); + onTouch(); + } + }} + onBlur={() => { + if (touched && value === "") { + onValueChange(SECRET_PLACEHOLDER); + onReset(); + } + }} + disabled={disabled} + /> +); + +const CustomHeadersFields: FC = ({ + 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 ( +
+

+ Enter custom headers to send with each request. Saving replaces existing + custom headers. +

+ {headers.map((header, index) => ( +
+ +
+ ))} + +
+ ); +}; + +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 }) => ( + <> + + { + const nextHeaders = [...headers]; + nextHeaders[index] = { ...header, key: event.target.value }; + setHeaders(nextHeaders); + }} + disabled={disabled} + /> + + + { + const nextHeaders = [...headers]; + nextHeaders[index] = { ...header, value: event.target.value }; + setHeaders(nextHeaders); + }} + disabled={disabled} + /> + + + +); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerBehaviorSection.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerBehaviorSection.tsx new file mode 100644 index 0000000000..e9e98f3885 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerBehaviorSection.tsx @@ -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; + formId: string; + disabled: boolean; +} + +export const MCPServerBehaviorSection: FC = ({ + form, + formId, + disabled, +}) => { + return ( + <> + option.value === form.values.availability, + )?.description + } + > + + +
+ + void form.setFieldValue("modelIntent", checked) + } + disabled={disabled} + tooltip="Allows this server to be used for model-intent tools." + /> + + void form.setFieldValue("allowInPlanMode", checked) + } + disabled={disabled} + tooltip="Allows tools during planning. Workspace MCP and plan-mode controls still apply." + /> + + void form.setFieldValue("forwardCoderHeaders", checked) + } + disabled={disabled} + tooltip="Only enable for first-party or trusted MCP servers." + /> +
+
+ + + + + + +
+ + ); +}; + +const SwitchField: FC<{ + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled: boolean; + tooltip: string; +}> = ({ label, checked, onCheckedChange, disabled, tooltip }) => ( +
+ + {label} + + + + + + {tooltip} + + +
+); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx new file mode 100644 index 0000000000..16754e2fdf --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -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; + 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; + onDeleteServer?: (serverId: string) => Promise; + onToggleEnabled?: (enabled: boolean) => void; + onCancel: () => void; +}; + +type MCPServerFormProps = MCPServerFormCreateProps | MCPServerFormEditProps; + +export const MCPServerForm: FC = ({ + 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({ + 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 ( + <> + setConfirmingDelete(true)} + onToggleEnabled={onToggleEnabled} + /> +
+ +
+ + + ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormDialogs.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormDialogs.tsx new file mode 100644 index 0000000000..81101648e3 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormDialogs.tsx @@ -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; + isDeleting: boolean; + unsavedChanges: ReturnType; +} + +export const MCPServerFormDialogs: FC = ({ + server, + confirmingDelete, + setConfirmingDelete, + onDeleteServer, + isDeleting, + unsavedChanges, +}) => { + return ( + <> + {server && onDeleteServer && ( + void onDeleteServer(server.id)} + isPending={isDeleting} + /> + )} + + +

+ Your updates haven't been saved. Leave anyway? +

+
+ } + /> + + ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFieldPrimitives.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFieldPrimitives.tsx new file mode 100644 index 0000000000..f678abf0fe --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFieldPrimitives.tsx @@ -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 = () => ( + * +); + +export const Field: FC<{ + label: ReactNode; + htmlFor?: string; + required?: boolean; + children: ReactNode; + description?: ReactNode; + className?: string; +}> = ({ label, htmlFor, required, children, description, className }) => { + return ( +
+ + {description && ( +

{description}

+ )} + {children} +
+ ); +}; + +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 ( + + + {open ? ( + + ) : ( + + )} +
+

+ {title} +

+

{description}

+
+
+ +
{children}
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx new file mode 100644 index 0000000000..40571a2c38 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -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; + 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 = ({ + form, + isSaving, + isDisabled, + canSubmit, + isEditing, + onCancel, + showDetails, + setShowDetails, + showAuth, + setShowAuth, + showBehavior, + setShowBehavior, +}) => { + const formId = useId(); + + return ( +
+
+
+ + { + void form.setFieldValue("slugTouched", true); + void form.setFieldValue("slug", event.target.value); + }} + placeholder="e.g. github, linear" + disabled={isDisabled} + /> + + + { + void form.setFieldValue("displayName", event.target.value); + if (!form.values.slugTouched) { + void form.setFieldValue("slug", slugify(event.target.value)); + } + }} + disabled={isDisabled} + /> + +
+ + + + + + + + +
+
+ +
+ + + + + + void form.setFieldValue("iconURL", value)} + onPickEmoji={(value) => + void form.setFieldValue("iconURL", value) + } + disabled={isDisabled} + /> + + + + + + + + + + +
+ +
+ + +
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx new file mode 100644 index 0000000000..ecbf0ca5f8 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx @@ -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 ( + + + + ); +}; + +interface MCPServerFormHeaderProps { + server?: TypesGen.MCPServerConfig; + title: string; + iconUrl: string; + isEditing: boolean; + isDisabled: boolean; + onRequestDelete: () => void; + onToggleEnabled?: (enabled: boolean) => void; +} + +export const MCPServerFormHeader: FC = ({ + server, + title, + iconUrl, + isEditing, + isDisabled, + onRequestDelete, + onToggleEnabled, +}) => { + return ( + <> +
+ + {isEditing && server && ( + + + + + + + + Remove + + + + )} +
+
+
+ {isEditing && ( + + )} + + + {title} + + + {isEditing && server && !server.enabled && ( + Disabled + )} +
+ {isEditing && server && ( +
+ + + + onToggleEnabled?.(checked)} + disabled={isDisabled} + aria-label="Server enabled" + /> + + + + {server.enabled + ? "Disable this server. It will be hidden from agents." + : "Enable this server. It will be visible to agents."} + + + Enable +
+ )} +
+ + ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerIcon.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerIcon.tsx new file mode 100644 index 0000000000..13cab876d1 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerIcon.tsx @@ -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 ( +
+ {iconUrl ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx new file mode 100644 index 0000000000..e6e266d738 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx @@ -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 = ({ server, onClick }) => { + const clickableProps = useClickableTableRow({ onClick }); + const enabled = server.enabled; + + return ( + + +
+ + + {server.display_name} + +
+
+ + {AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type} + + + {AVAILABILITY_LABELS[server.availability] ?? server.availability} + + + {enabled ? "Enabled" : "Disabled"} + + + + +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.test.ts b/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.test.ts new file mode 100644 index 0000000000..b4a3a3e42b --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.test.ts @@ -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 => ({ + ...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" }); + }); +}); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.ts b/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.ts new file mode 100644 index 0000000000..97f6c2568e --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/mcpServerFormLogic.ts @@ -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; + +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; + +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 ?? [])], + }; +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/testFixtures.ts b/site/src/pages/AISettingsPage/MCPServersPage/testFixtures.ts new file mode 100644 index 0000000000..245135b0ec --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/testFixtures.ts @@ -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, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx b/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx deleted file mode 100644 index dbb9b11839..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx +++ /dev/null @@ -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 ( - - 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} - /> - - ); -}; - -export default AgentSettingsMCPServersPage; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderField.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderField.tsx deleted file mode 100644 index 1c6e0ec259..0000000000 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderField.tsx +++ /dev/null @@ -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 = ({ - label, - htmlFor, - required, - description, - children, -}) => ( -
-
- - {required && ( - * - )} -
- {description && ( -

{description}

- )} - {children} -
-); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index 757656c63a..29fc8fe476 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -180,9 +180,9 @@ export const SettingsPanel: FC = ({ & - Pick, -): TypesGen.MCPServerConfig => ({ - ...MockMCPServerConfig, - created_at: now, - updated_at: now, - ...overrides, -}); - -// ── Meta ─────────────────────────────────────────────────────── - -const meta: Meta = { - 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; - -// ── 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).mock - .calls[0][0]; - expect(call).not.toHaveProperty("oauth2_client_id"); - expect(call).not.toHaveProperty("api_key_value"); - expect(call).not.toHaveProperty("custom_headers"); - }, -}; diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx deleted file mode 100644 index 2ec4cabcb2..0000000000 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ /dev/null @@ -1,1285 +0,0 @@ -import { useFormik } from "formik"; -import { - CheckCircleIcon, - ChevronDownIcon, - ChevronRightIcon, - CircleIcon, - PlusIcon, - ServerIcon, - XIcon, -} from "lucide-react"; -import { type FC, lazy, 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 { 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, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { Switch } from "#/components/Switch/Switch"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { cn } from "#/utils/cn"; -import { BackButton } from "./BackButton"; -import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderField"; -import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; -import { SectionHeader } from "./SectionHeader"; - -// ── Constants ────────────────────────────────────────────────── - -const SECRET_PLACEHOLDER = "••••••••••••••••"; - -const TRANSPORT_OPTIONS = [ - { value: "streamable_http", label: "Streamable HTTP" }, - { value: "sse", label: "SSE" }, -] as const; - -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; - -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; - -// ── Helpers ──────────────────────────────────────────────────── - -const slugify = (value: string): string => - value - .toLowerCase() - .trim() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, ""); - -const splitList = (value: string): string[] => - value - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - -const joinList = (arr: readonly string[] | undefined): string => - arr?.join(", ") ?? ""; - -const authTypeLabel = (t: string) => - AUTH_TYPE_OPTIONS.find((o) => o.value === t)?.label ?? t; - -// ── Server icon ──────────────────────────────────────────────── - -const MCPServerIcon: FC<{ - iconUrl: string; - name: string; - className?: string; -}> = ({ iconUrl, name, className }) => { - if (iconUrl) { - return ( -
- -
- ); - } - return ( -
- -
- ); -}; - -// ── 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 { - servers: readonly TypesGen.MCPServerConfig[]; - onSelect: (server: TypesGen.MCPServerConfig) => void; - onAdd: () => void; - sectionLabel?: string; - sectionDescription?: string; -} - -const ServerList: FC = ({ - servers, - onSelect, - onAdd, - sectionLabel, - sectionDescription, -}) => { - return ( - <> - - - Add server - - } - /> - - {servers.length === 0 ? ( -
-

- No MCP servers configured yet. -

- -
- ) : ( -
- {servers.map((server, i) => ( - - ))} -
- )} - - ); -}; - -// ── Server Form ──────────────────────────────────────────────── - -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; -} - -const buildInitialValues = ( - server: TypesGen.MCPServerConfig | null, -): MCPServerFormValues => ({ - displayName: server?.display_name ?? "", - slug: server?.slug ?? "", - slugTouched: false, - 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: joinList(server?.tool_allow_list), - toolDenyList: joinList(server?.tool_deny_list), - customHeaders: [], - customHeadersTouched: false, -}); - -interface ServerFormProps { - server: TypesGen.MCPServerConfig | null; - isSaving: boolean; - isDeleting: boolean; - onSave: ( - req: TypesGen.CreateMCPServerConfigRequest, - id?: string, - ) => Promise; - onDelete: (id: string) => Promise; - onBack: () => void; -} - -const ServerForm: FC = ({ - server, - isSaving, - isDeleting, - onSave, - onDelete, - onBack, -}) => { - 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), - onSubmit: async (values) => { - const effectiveOAuth2Secret = - values.oauth2SecretTouched && - values.oauth2ClientSecret !== SECRET_PLACEHOLDER - ? values.oauth2ClientSecret - : undefined; - const effectiveApiKeyValue = - values.apiKeyTouched && values.apiKeyValue !== SECRET_PLACEHOLDER - ? values.apiKeyValue - : undefined; - - const req: 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, - ...(values.authType === "oauth2" && { - oauth2_client_id: values.oauth2ClientID.trim(), - oauth2_client_secret: effectiveOAuth2Secret, - oauth2_auth_url: values.oauth2AuthURL.trim() || undefined, - oauth2_token_url: values.oauth2TokenURL.trim() || undefined, - oauth2_scopes: values.oauth2Scopes.trim() || undefined, - }), - ...(values.authType === "api_key" && { - api_key_header: values.apiKeyHeader.trim() || undefined, - api_key_value: effectiveApiKeyValue, - }), - ...(values.authType === "custom_headers" && - values.customHeadersTouched && { - custom_headers: Object.fromEntries( - values.customHeaders - .filter((h) => h.key.trim() !== "") - .map((h) => [h.key.trim(), h.value]), - ), - }), - tool_allow_list: splitList(values.toolAllowList), - tool_deny_list: splitList(values.toolDenyList), - }; - - await onSave(req, server?.id); - }, - }); - - const isDisabled = isSaving || isDeleting; - const canSubmit = - form.values.displayName.trim() !== "" && - form.values.slug.trim() !== "" && - form.values.url.trim() !== "" && - !isDisabled; - - return ( -
- -
- - {form.values.displayName || - (isEditing ? "Server display name" : "New MCP server")} - - {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."} - - - )} -
-
-
-
-
- - { - form.setFieldValue("displayName", e.target.value); - if (!form.values.slugTouched) { - form.setFieldValue("slug", slugify(e.target.value)); - } - }} - placeholder="e.g. Sentry" - disabled={isDisabled} - aria-label="Display name" - /> - - - { - form.setFieldValue("slugTouched", true); - form.setFieldValue("slug", e.target.value); - }} - placeholder="e.g. sentry" - disabled={isDisabled} - /> - -
- - - - - - - -
-
- - {/* ── Details section ── */} - -
- - - - - - - - - { - 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} - /> - -
-
- - - - - - -
- - - -
- )} - - {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.values.authType === "user_oidc" && ( -
-

- The calling user's OIDC access token is forwarded to this - MCP server in the Authorization header. - Tokens are refreshed transparently before each request. -

-

- Users who did not log in via OIDC (for example, password - or GitHub login) will see requests sent without an - authorization header. Configure no other fields for this - auth type. -

-
- )} -
-
-
- {/* ── 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} - /> -
- -
-
- -

- When enabled, every outgoing MCP request includes the - Coder owner, chat, subchat, and workspace IDs as - X-Coder-* headers. Off by default. Only - enable for first-party or trusted MCP servers. -

-
- { - form.setFieldValue("forwardCoderHeaders", v); - }} - disabled={isDisabled} - /> -
- -
- - - - - - -
-
-
-
-
- -
-
-
- {isEditing ? ( - - ) : ( - - )} - -
-
-
- {server && ( - void onDelete(server.id)} - isPending={isDeleting} - open={confirmingDelete} - onOpenChange={(open) => !open && setConfirmingDelete(false)} - /> - )} -
- ); -}; - -// ── Main Panel ───────────────────────────────────────────────── - -interface MCPServerAdminPanelProps { - className?: string; - sectionLabel?: string; - sectionDescription?: string; - // Data from query. - serversData: TypesGen.MCPServerConfig[] | undefined; - isLoadingServers: boolean; - serversError: Error | null; - // Mutation handlers. - onCreateServer: ( - req: TypesGen.CreateMCPServerConfigRequest, - ) => Promise; - onUpdateServer: (args: { - id: string; - req: TypesGen.UpdateMCPServerConfigRequest; - }) => Promise; - onDeleteServer: (id: string) => Promise; - isCreatingServer: boolean; - isUpdatingServer: boolean; - isDeletingServer: boolean; - createError: Error | null; - updateError: Error | null; - deleteError: Error | null; -} - -export const MCPServerAdminPanel: FC = ({ - className, - sectionLabel, - sectionDescription, - serversData, - isLoadingServers, - serversError, - onCreateServer, - onUpdateServer, - onDeleteServer, - isCreatingServer, - isUpdatingServer, - isDeletingServer, - createError, - updateError, - deleteError, -}) => { - 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() - .sort((a, b) => a.display_name.localeCompare(b.display_name)); - - const editingServer = - serverId && serverId !== "new" - ? (servers.find((s) => s.id === serverId) ?? null) - : null; - const isFormView = serverId !== null; - const isCreating = serverId === "new"; - - const handleSave = async ( - req: TypesGen.CreateMCPServerConfigRequest, - id?: string, - ) => { - if (id) { - const updateReq: TypesGen.UpdateMCPServerConfigRequest = { - ...req, - tool_allow_list: req.tool_allow_list - ? [...req.tool_allow_list] - : undefined, - tool_deny_list: req.tool_deny_list - ? [...req.tool_deny_list] - : undefined, - }; - try { - await onUpdateServer({ id, req: updateReq }); - } catch { - // Error surfaced via mutation error state. - return; - } - } else { - try { - await onCreateServer(req); - } catch { - // Error surfaced via mutation error state. - return; - } - } - exitServerView(); - }; - - const handleDelete = async (id: string) => { - try { - await onDeleteServer(id); - } catch { - // Error surfaced via mutation error state. - return; - } - exitServerView(); - }; - - return ( -
- {isLoadingServers && ( -
- - Loading -
- )} - {/* Content */} -
- {!isFormView ? ( - - setSearchParams( - { server: server.id }, - { state: { pushed: true } }, - ) - } - onAdd={() => - setSearchParams({ server: "new" }, { state: { pushed: true } }) - } - sectionLabel={sectionLabel} - sectionDescription={sectionDescription} - /> - ) : isCreating || (!isLoadingServers && editingServer) ? ( - - ) : null} -
- - {serversError && } - {createError && } - {updateError && } - {deleteError && } -
- ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 0a16a84894..cccbc7bdd3 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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 ; } + if (permissions.editDeploymentConfig) { + return ; + } + return ; }; @@ -767,6 +783,15 @@ export const router = createBrowserRouter( path="models/:modelId" element={} /> + } /> + } + /> + } + /> } /> } + element={} /> } /> } />