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