feat(site): add MCP server admin UI (#23301)

This adds the UI but does not add it to the Settings sidebar. Until it's
actually functional and usable (which will come in future PRs) it will
remain hidden.

Next step is wiring this up to chats and actually testing the full flow
end-to-end, but we aren't there yet.
This commit is contained in:
Kyle Carberry
2026-03-19 14:53:35 -04:00
committed by GitHub
parent a908d51097
commit 7db77bbefa
6 changed files with 1755 additions and 2 deletions
+35
View File
@@ -402,6 +402,7 @@ export type DeploymentConfig = Readonly<{
const chatProviderConfigsPath = "/api/experimental/chats/providers";
const chatModelConfigsPath = "/api/experimental/chats/model-configs";
const mcpServerConfigsPath = "/api/experimental/mcp/servers";
type ChatCostDateParams = {
start_date?: string;
@@ -3221,6 +3222,40 @@ class ApiMethods {
`${chatModelConfigsPath}/${encodeURIComponent(modelConfigId)}`,
);
};
getMCPServerConfigs = async (): Promise<TypesGen.MCPServerConfig[]> => {
const response =
await this.axios.get<TypesGen.MCPServerConfig[]>(mcpServerConfigsPath);
return response.data;
};
createMCPServerConfig = async (
req: TypesGen.CreateMCPServerConfigRequest,
): Promise<TypesGen.MCPServerConfig> => {
const response = await this.axios.post<TypesGen.MCPServerConfig>(
mcpServerConfigsPath,
req,
);
return response.data;
};
updateMCPServerConfig = async (
id: string,
req: TypesGen.UpdateMCPServerConfigRequest,
): Promise<TypesGen.MCPServerConfig> => {
const response = await this.axios.patch<TypesGen.MCPServerConfig>(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}`,
req,
);
return response.data;
};
deleteMCPServerConfig = async (id: string): Promise<void> => {
await this.axios.delete(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}`,
);
};
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
+41
View File
@@ -631,3 +631,44 @@ export const deleteChatUsageLimitGroupOverride = (
});
},
});
// ── MCP Server Configs ───────────────────────────────────────
const mcpServerConfigsKey = ["mcp-server-configs"] as const;
export const mcpServerConfigs = () => ({
queryKey: mcpServerConfigsKey,
queryFn: (): Promise<TypesGen.MCPServerConfig[]> => API.getMCPServerConfigs(),
});
const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => {
await queryClient.invalidateQueries({ queryKey: mcpServerConfigsKey });
};
export const createMCPServerConfig = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateMCPServerConfigRequest) =>
API.createMCPServerConfig(req),
onSuccess: async () => {
await invalidateMCPServerConfigQueries(queryClient);
},
});
type UpdateMCPServerConfigMutationArgs = {
id: string;
req: TypesGen.UpdateMCPServerConfigRequest;
};
export const updateMCPServerConfig = (queryClient: QueryClient) => ({
mutationFn: ({ id, req }: UpdateMCPServerConfigMutationArgs) =>
API.updateMCPServerConfig(id, req),
onSuccess: async () => {
await invalidateMCPServerConfigQueries(queryClient);
},
});
export const deleteMCPServerConfig = (queryClient: QueryClient) => ({
mutationFn: (id: string) => API.deleteMCPServerConfig(id),
onSuccess: async () => {
await invalidateMCPServerConfigQueries(queryClient);
},
});
@@ -317,13 +317,13 @@ export const ProviderForm: FC<ProviderFormProps> = ({
interface ProviderFieldProps {
label: string;
htmlFor: string;
htmlFor?: string;
required?: boolean;
description?: string;
children: React.ReactNode;
}
const ProviderField: FC<ProviderFieldProps> = ({
export const ProviderField: FC<ProviderFieldProps> = ({
label,
htmlFor,
required,
@@ -0,0 +1,688 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
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 => ({
id: overrides.id,
display_name: overrides.display_name,
slug: overrides.slug,
description: overrides.description ?? "",
icon_url: overrides.icon_url ?? "",
transport: overrides.transport ?? "streamable_http",
url: overrides.url ?? "https://mcp.example.com/sse",
auth_type: overrides.auth_type ?? "none",
oauth2_client_id: overrides.oauth2_client_id,
has_oauth2_secret: overrides.has_oauth2_secret ?? false,
oauth2_auth_url: overrides.oauth2_auth_url,
oauth2_token_url: overrides.oauth2_token_url,
oauth2_scopes: overrides.oauth2_scopes,
api_key_header: overrides.api_key_header,
has_api_key: overrides.has_api_key ?? false,
has_custom_headers: overrides.has_custom_headers ?? false,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
auth_connected: overrides.auth_connected ?? false,
});
/**
* Set up spies for MCP server config API methods. The mutable
* `state` object lets mutation spies update what queries return
* on refetch, mimicking a real server round-trip.
*/
const setupMCPSpies = (state: { servers: TypesGen.MCPServerConfig[] }) => {
spyOn(API, "getMCPServerConfigs").mockImplementation(async () => {
return state.servers;
});
spyOn(API, "createMCPServerConfig").mockImplementation(async (req) => {
const created = createServerConfig({
id: `mcp-${Date.now()}`,
display_name: req.display_name,
slug: req.slug,
description: req.description,
icon_url: req.icon_url,
transport: req.transport,
url: req.url,
auth_type: req.auth_type,
availability: req.availability,
enabled: req.enabled,
has_oauth2_secret: (req.oauth2_client_secret ?? "").length > 0,
has_api_key: (req.api_key_value ?? "").length > 0,
has_custom_headers:
req.custom_headers != null &&
Object.keys(req.custom_headers).length > 0,
tool_allow_list: req.tool_allow_list ?? [],
tool_deny_list: req.tool_deny_list ?? [],
});
state.servers = [...state.servers, created];
return created;
});
spyOn(API, "updateMCPServerConfig").mockImplementation(async (id, req) => {
const idx = state.servers.findIndex((s) => s.id === id);
if (idx < 0) {
throw new Error("MCP server config not found.");
}
const current = state.servers[idx];
const updated: TypesGen.MCPServerConfig = {
...current,
display_name: req.display_name ?? current.display_name,
slug: req.slug ?? current.slug,
description: req.description ?? current.description,
url: req.url ?? current.url,
transport: req.transport ?? current.transport,
auth_type: req.auth_type ?? current.auth_type,
availability: req.availability ?? current.availability,
enabled: req.enabled ?? current.enabled,
updated_at: now,
};
state.servers = state.servers.map((s, i) => (i === idx ? updated : s));
return updated;
});
spyOn(API, "deleteMCPServerConfig").mockImplementation(async (id) => {
state.servers = state.servers.filter((s) => s.id !== id);
});
};
// ── 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" },
}),
},
};
export default meta;
type Story = StoryObj<typeof MCPServerAdminPanel>;
// ── Stories ────────────────────────────────────────────────────
/** Empty state with no servers configured. */
export const EmptyState: Story = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByText(/No MCP servers configured yet/i),
).toBeInTheDocument();
await expect(
body.getByRole("button", { name: /Add Server/i }),
).toBeInTheDocument();
},
};
/** List view with multiple servers showing status indicators. */
export const ServerList: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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();
},
};
/** Navigate to the create form and fill it out. */
export const CreateServer: Story = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
// Click Add Server.
await userEvent.click(
await body.findByRole("button", { name: /Add Server/i }),
);
// Fill in the display name via the inline header input.
const nameInput = await body.findByLabelText(/Display Name/i);
await userEvent.type(nameInput, "Sentry");
// Slug should auto-populate.
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(API.createMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.createMCPServerConfig).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 = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add 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/",
);
// 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(API.createMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.createMCPServerConfig).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 = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add 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",
);
// 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(API.createMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.createMCPServerConfig).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 = {
beforeEach: () => {
setupMCPSpies({
servers: [
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 }) => {
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");
expect(body.getByLabelText(/^Slug/i)).toHaveValue("sentry");
expect(body.getByLabelText(/Server URL/i)).toHaveValue(
"https://mcp.sentry.io/sse",
);
// 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(API.updateMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.updateMCPServerConfig).toHaveBeenCalledWith(
"mcp-sentry",
expect.objectContaining({
description: "Sentry error tracking integration",
}),
);
},
};
/** Edit a server that has OAuth2 — secret field should show placeholder. */
export const EditServerWithOAuth2Secret: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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/ }));
// 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 = {
beforeEach: () => {
setupMCPSpies({
servers: [
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 }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Custom API/ }),
);
// 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(API.updateMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.updateMCPServerConfig).toHaveBeenCalledWith(
"mcp-custom",
expect.objectContaining({
custom_headers: { Authorization: "Bearer tok_abc" },
}),
);
},
};
/** Delete a server with confirmation step. */
export const DeleteServerConfirmation: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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 should appear.
await expect(
await body.findByText(/Are you sure\? This action is irreversible/i),
).toBeInTheDocument();
},
};
/** Cancel delete returns to normal form footer. */
export const DeleteServerCancelled: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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/i);
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
// Normal footer should be restored.
await expect(
await body.findByRole("button", { name: "Delete" }),
).toBeInTheDocument();
expect(
body.getByRole("button", { name: /Save changes/i }),
).toBeInTheDocument();
},
};
/** Confirm delete calls the API. */
export const DeleteServerConfirmed: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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/i);
await userEvent.click(body.getByRole("button", { name: /Delete server/i }));
await waitFor(() => {
expect(API.deleteMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.deleteMCPServerConfig).toHaveBeenCalledWith("mcp-sentry");
},
};
/** Navigate to form and back without saving. */
export const BackToList: Story = {
beforeEach: () => {
setupMCPSpies({
servers: [
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: /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(API.createMCPServerConfig).not.toHaveBeenCalled();
},
};
/** Create a server with tool allow/deny lists. */
export const CreateServerWithToolGovernance: Story = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add 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.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(API.createMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.createMCPServerConfig).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 = {
beforeEach: () => {
setupMCPSpies({ servers: [] });
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add 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",
);
// 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(API.createMCPServerConfig).toHaveBeenCalledTimes(1);
});
expect(API.createMCPServerConfig).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "custom_headers",
custom_headers: { "X-Api-Token": "secret-token-123" },
}),
);
},
};
@@ -0,0 +1,981 @@
import {
createMCPServerConfig as createMCPServerConfigMutation,
deleteMCPServerConfig as deleteMCPServerConfigMutation,
mcpServerConfigs,
updateMCPServerConfig as updateMCPServerConfigMutation,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { IconField } from "components/IconField/IconField";
import { Input } from "components/Input/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import {
CheckCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleIcon,
PlusIcon,
ServerIcon,
XIcon,
} from "lucide-react";
import {
type FC,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import { cn } from "utils/cn";
import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderForm";
import { SectionHeader } from "./SectionHeader";
// ── Constants ──────────────────────────────────────────────────
const SECRET_PLACEHOLDER = "••••••••••••••••";
const TRANSPORT_OPTIONS = [
{ value: "streamable_http", label: "Streamable HTTP" },
{ value: "sse", label: "SSE" },
] as const;
const AUTH_TYPE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "oauth2", label: "OAuth2" },
{ value: "api_key", label: "API Key" },
{ value: "custom_headers", label: "Custom Headers" },
] as const;
const AVAILABILITY_OPTIONS = [
{
value: "force_on",
label: "Force On",
description: "Always injected into every chat session.",
},
{
value: "default_on",
label: "Default On",
description: "Pre-selected but users can opt out.",
},
{
value: "default_off",
label: "Default Off",
description: "Available but users must opt in.",
},
] as const;
// ── Helpers ────────────────────────────────────────────────────
const slugify = (value: string): string =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
const splitList = (value: string): string[] =>
value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const joinList = (arr: readonly string[] | undefined): string =>
arr?.join(", ") ?? "";
const authTypeLabel = (t: string) =>
AUTH_TYPE_OPTIONS.find((o) => o.value === t)?.label ?? t;
// ── Server icon ────────────────────────────────────────────────
const MCPServerIcon: FC<{
iconUrl: string;
name: string;
className?: string;
}> = ({ iconUrl, name, className }) => {
if (iconUrl) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
className,
)}
>
<ExternalImage
src={iconUrl}
alt={`${name} icon`}
className="h-3/5 w-3/5"
/>
</div>
);
}
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
className,
)}
>
<ServerIcon className="h-3/5 w-3/5 text-content-secondary" />
</div>
);
};
// ── Server List ────────────────────────────────────────────────
interface ServerListProps {
servers: readonly TypesGen.MCPServerConfig[];
onSelect: (server: TypesGen.MCPServerConfig) => void;
onAdd: () => void;
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
}
const ServerList: FC<ServerListProps> = ({
servers,
onSelect,
onAdd,
sectionLabel,
sectionDescription,
sectionBadge,
}) => (
<>
<SectionHeader
label={sectionLabel ?? "MCP Servers"}
description={
sectionDescription ??
"Configure external MCP servers that provide additional tools for AI chat sessions."
}
badge={sectionBadge}
action={
<Button size="sm" onClick={onAdd}>
<PlusIcon className="h-4 w-4" />
Add Server
</Button>
}
/>
{servers.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-6 text-center text-[13px] text-content-secondary">
No MCP servers configured yet. Add a server to get started.
</div>
) : (
<div>
{servers.map((server, i) => (
<button
key={server.id}
type="button"
onClick={() => onSelect(server)}
aria-label={`${server.display_name} (${server.enabled ? "enabled" : "disabled"})`}
className={cn(
"flex w-full cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 px-3 py-3 text-left transition-colors hover:bg-surface-secondary/30",
i > 0 && "border-0 border-t border-solid border-border/50",
)}
>
<MCPServerIcon
iconUrl={server.icon_url}
name={server.display_name}
className="h-8 w-8"
/>
<div className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-medium text-content-primary text-left">
{server.display_name}
</span>
<span className="block truncate text-xs text-content-secondary">
{server.url} · {authTypeLabel(server.auth_type)}
</span>
</div>
{server.enabled ? (
<CheckCircleIcon className="h-4 w-4 shrink-0 text-content-success" />
) : (
<CircleIcon className="h-4 w-4 shrink-0 text-content-secondary opacity-40" />
)}
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
</button>
))}
</div>
)}
</>
);
// ── Server Form ────────────────────────────────────────────────
interface MCPServerFormValues {
displayName: string;
slug: string;
slugTouched: boolean;
description: string;
iconURL: string;
url: string;
transport: string;
authType: string;
oauth2ClientID: string;
oauth2ClientSecret: string;
oauth2SecretTouched: boolean;
oauth2AuthURL: string;
oauth2TokenURL: string;
oauth2Scopes: string;
apiKeyHeader: string;
apiKeyValue: string;
apiKeyTouched: boolean;
availability: string;
enabled: boolean;
toolAllowList: string;
toolDenyList: string;
customHeaders: Array<{ key: string; value: string }>;
customHeadersTouched: boolean;
}
const buildInitialValues = (
server: TypesGen.MCPServerConfig | null,
): MCPServerFormValues => ({
displayName: server?.display_name ?? "",
slug: server?.slug ?? "",
slugTouched: false,
description: server?.description ?? "",
iconURL: server?.icon_url ?? "",
url: server?.url ?? "",
transport: server?.transport ?? "streamable_http",
authType: server?.auth_type ?? "none",
oauth2ClientID: server?.oauth2_client_id ?? "",
oauth2ClientSecret: server?.has_oauth2_secret ? SECRET_PLACEHOLDER : "",
oauth2SecretTouched: false,
oauth2AuthURL: server?.oauth2_auth_url ?? "",
oauth2TokenURL: server?.oauth2_token_url ?? "",
oauth2Scopes: server?.oauth2_scopes ?? "",
apiKeyHeader: server?.api_key_header ?? "",
apiKeyValue: server?.has_api_key ? SECRET_PLACEHOLDER : "",
apiKeyTouched: false,
availability: server?.availability ?? "default_off",
enabled: server?.enabled ?? true,
toolAllowList: joinList(server?.tool_allow_list),
toolDenyList: joinList(server?.tool_deny_list),
customHeaders: [],
customHeadersTouched: false,
});
interface ServerFormProps {
server: TypesGen.MCPServerConfig | null;
isSaving: boolean;
isDeleting: boolean;
onSave: (
req: TypesGen.CreateMCPServerConfigRequest,
id?: string,
) => Promise<unknown>;
onDelete: (id: string) => Promise<void>;
onBack: () => void;
}
const ServerForm: FC<ServerFormProps> = ({
server,
isSaving,
isDeleting,
onSave,
onDelete,
onBack,
}) => {
const formId = useId();
const isEditing = server !== null;
const [confirmingDelete, setConfirmingDelete] = useState(false);
const form = useFormik<MCPServerFormValues>({
initialValues: buildInitialValues(server),
onSubmit: async (values) => {
const effectiveOAuth2Secret =
values.oauth2SecretTouched &&
values.oauth2ClientSecret !== SECRET_PLACEHOLDER
? values.oauth2ClientSecret
: undefined;
const effectiveApiKeyValue =
values.apiKeyTouched && values.apiKeyValue !== SECRET_PLACEHOLDER
? values.apiKeyValue
: undefined;
const req: TypesGen.CreateMCPServerConfigRequest = {
display_name: values.displayName.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
icon_url: values.iconURL.trim(),
url: values.url.trim(),
transport: values.transport,
auth_type: values.authType,
availability: values.availability,
enabled: values.enabled,
...(values.authType === "oauth2" && {
oauth2_client_id: values.oauth2ClientID.trim(),
oauth2_client_secret: effectiveOAuth2Secret,
oauth2_auth_url: values.oauth2AuthURL.trim() || undefined,
oauth2_token_url: values.oauth2TokenURL.trim() || undefined,
oauth2_scopes: values.oauth2Scopes.trim() || undefined,
}),
...(values.authType === "api_key" && {
api_key_header: values.apiKeyHeader.trim() || undefined,
api_key_value: effectiveApiKeyValue,
}),
...(values.authType === "custom_headers" &&
values.customHeadersTouched && {
custom_headers: Object.fromEntries(
values.customHeaders
.filter((h) => h.key.trim() !== "")
.map((h) => [h.key.trim(), h.value]),
),
}),
tool_allow_list: splitList(values.toolAllowList),
tool_deny_list: splitList(values.toolDenyList),
};
await onSave(req, server?.id);
},
});
const isDisabled = isSaving || isDeleting;
const canSubmit =
form.values.displayName.trim() !== "" &&
form.values.slug.trim() !== "" &&
form.values.url.trim() !== "" &&
!isDisabled;
return (
<div className="flex min-h-full flex-col">
{/* Back */}
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronLeftIcon className="h-4 w-4" />
Back
</button>
{/* Header with icon + editable name + enabled toggle */}
<div className="flex items-center gap-3">
<MCPServerIcon
iconUrl={form.values.iconURL}
name={form.values.displayName || "New Server"}
className="h-8 w-8"
/>
<input
type="text"
value={form.values.displayName}
onChange={(e) => {
form.setFieldValue("displayName", e.target.value);
if (!form.values.slugTouched) {
form.setFieldValue("slug", slugify(e.target.value));
}
}}
disabled={isDisabled}
className="m-0 min-w-0 flex-1 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder="Server display name"
aria-label="Display Name"
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
<Switch
checked={form.values.enabled}
onCheckedChange={(v) => {
form.setFieldValue("enabled", v);
}}
aria-label="Enabled"
disabled={isDisabled}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{form.values.enabled ? "Disable" : "Enable"} this server
</TooltipContent>
</Tooltip>
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
<form
id={formId}
onSubmit={form.handleSubmit}
className="flex flex-1 flex-col"
autoComplete="off"
>
<div className="space-y-5">
{" "}
{/* ── Identity row: slug + description side by side ── */}
<div className="grid items-start gap-5 sm:grid-cols-2">
{" "}
<Field
label="Slug"
htmlFor={`${formId}-slug`}
required
description="URL-safe identifier."
>
<Input
id={`${formId}-slug`}
className="h-9 text-[13px]"
value={form.values.slug}
onChange={(e) => {
form.setFieldValue("slugTouched", true);
form.setFieldValue("slug", e.target.value);
}}
placeholder="e.g. sentry"
disabled={isDisabled}
/>
</Field>
<Field
label="Description"
htmlFor={`${formId}-desc`}
description="Brief summary of what this server provides."
>
<Input
id={`${formId}-desc`}
className="h-9 text-[13px]"
{...form.getFieldProps("description")}
placeholder="Optional description"
disabled={isDisabled}
/>
</Field>
</div>
<Field
label="Icon"
description="Pick an emoji or paste an image URL."
>
<IconField
value={form.values.iconURL}
onChange={(e) => {
form.setFieldValue("iconURL", e.target.value);
}}
onPickEmoji={(value) => {
form.setFieldValue("iconURL", value);
}}
disabled={isDisabled}
/>
</Field>
{/* ── Connection row: URL + transport side by side ── */}
<hr className="!my-2 border-0 border-t border-solid border-border" />
<div className="grid items-start gap-5 sm:grid-cols-[1fr_auto]">
<Field
label="Server URL"
htmlFor={`${formId}-url`}
required
description="The endpoint URL for this MCP server."
>
<Input
id={`${formId}-url`}
className="h-9 text-[13px]"
{...form.getFieldProps("url")}
placeholder="https://mcp.example.com/sse"
disabled={isDisabled}
/>
</Field>
<Field label="Transport" htmlFor={`${formId}-transport`}>
<Select
value={form.values.transport}
onValueChange={(v) => {
form.setFieldValue("transport", v);
}}
disabled={isDisabled}
>
<SelectTrigger
id={`${formId}-transport`}
className="h-9 min-w-[160px] text-[13px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{/* ── Authentication ── */}
<hr className="!my-2 border-0 border-t border-solid border-border" />
<Field
label="Authentication"
htmlFor={`${formId}-auth`}
description="How users authenticate with this MCP server."
>
<Select
value={form.values.authType}
onValueChange={(v) => {
form.setFieldValue("authType", v);
}}
disabled={isDisabled}
>
<SelectTrigger
id={`${formId}-auth`}
className="h-9 max-w-[240px] text-[13px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{form.values.authType === "oauth2" && (
<div className="space-y-4 rounded-lg border border-border bg-surface-secondary/30 p-4">
<p className="m-0 text-xs 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="h-9 text-[13px]"
{...form.getFieldProps("oauth2ClientID")}
disabled={isDisabled}
/>
</Field>
<Field label="Client Secret" htmlFor={`${formId}-oauth-secret`}>
<Input
id={`${formId}-oauth-secret`}
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={form.values.oauth2ClientSecret}
onChange={(e) => {
form.setFieldValue("oauth2SecretTouched", true);
form.setFieldValue("oauth2ClientSecret", e.target.value);
}}
onFocus={() => {
if (
!form.values.oauth2SecretTouched &&
form.values.oauth2ClientSecret === SECRET_PLACEHOLDER
) {
form.setFieldValue("oauth2ClientSecret", "");
form.setFieldValue("oauth2SecretTouched", true);
}
}}
disabled={isDisabled}
/>
</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="h-9 text-[13px]"
{...form.getFieldProps("oauth2AuthURL")}
placeholder="https://provider.com/oauth2/authorize"
disabled={isDisabled}
/>
</Field>
<Field label="Token URL" htmlFor={`${formId}-oauth-token-url`}>
<Input
id={`${formId}-oauth-token-url`}
className="h-9 text-[13px]"
{...form.getFieldProps("oauth2TokenURL")}
placeholder="https://provider.com/oauth2/token"
disabled={isDisabled}
/>
</Field>
</div>
<Field label="Scopes" htmlFor={`${formId}-oauth-scopes`}>
<Input
id={`${formId}-oauth-scopes`}
className="h-9 text-[13px]"
{...form.getFieldProps("oauth2Scopes")}
placeholder="read write"
disabled={isDisabled}
/>
</Field>
</div>
)}
{form.values.authType === "api_key" && (
<div className="grid items-start gap-4 rounded-lg border border-border bg-surface-secondary/30 p-4 sm:grid-cols-2">
<Field label="Header Name" htmlFor={`${formId}-apikey-header`}>
<Input
id={`${formId}-apikey-header`}
className="h-9 text-[13px]"
{...form.getFieldProps("apiKeyHeader")}
placeholder="Authorization"
disabled={isDisabled}
/>
</Field>
<Field label="API Key" htmlFor={`${formId}-apikey-value`}>
<Input
id={`${formId}-apikey-value`}
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={form.values.apiKeyValue}
onChange={(e) => {
form.setFieldValue("apiKeyTouched", true);
form.setFieldValue("apiKeyValue", e.target.value);
}}
onFocus={() => {
if (
!form.values.apiKeyTouched &&
form.values.apiKeyValue === SECRET_PLACEHOLDER
) {
form.setFieldValue("apiKeyValue", "");
form.setFieldValue("apiKeyTouched", true);
}
}}
disabled={isDisabled}
/>
</Field>
</div>
)}
{form.values.authType === "custom_headers" && (
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/30 p-4">
{server?.has_custom_headers &&
!form.values.customHeadersTouched && (
<p className="m-0 text-xs text-content-secondary">
This server has custom headers configured. Add headers below
to replace them.
</p>
)}
{form.values.customHeaders.map((header, index) => (
<div key={index} className="flex items-start gap-2">
<div className="grid flex-1 items-start gap-2 sm:grid-cols-2">
<Input
className="h-9 text-[13px]"
value={header.key}
onChange={(e) => {
form.setFieldValue("customHeadersTouched", true);
const updated = [...form.values.customHeaders];
updated[index] = {
...updated[index],
key: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="Header name"
disabled={isDisabled}
aria-label={`Header ${index + 1} name`}
/>
<Input
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={header.value}
onChange={(e) => {
form.setFieldValue("customHeadersTouched", true);
const updated = [...form.values.customHeaders];
updated[index] = {
...updated[index],
value: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="Header value"
disabled={isDisabled}
aria-label={`Header ${index + 1} value`}
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
className="mt-0 h-9 w-9 shrink-0"
onClick={() => {
form.setFieldValue("customHeadersTouched", true);
form.setFieldValue(
"customHeaders",
form.values.customHeaders.filter((_, i) => i !== index),
);
}}
disabled={isDisabled}
aria-label={`Remove header ${index + 1}`}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
form.setFieldValue("customHeadersTouched", true);
form.setFieldValue("customHeaders", [
...form.values.customHeaders,
{ key: "", value: "" },
]);
}}
disabled={isDisabled}
>
<PlusIcon className="h-4 w-4" />
Add header
</Button>
</div>
)}
{/* ── Availability ── */}
<hr className="!my-2 border-0 border-t border-solid border-border" />
<Field
label="Availability"
htmlFor={`${formId}-availability`}
description="Controls how this server appears in new chats."
>
<Select
value={form.values.availability}
onValueChange={(v) => {
form.setFieldValue("availability", v);
}}
disabled={isDisabled}
>
<SelectTrigger
id={`${formId}-availability`}
className="h-9 text-[13px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABILITY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div>
<span>{opt.label}</span>
<span className="ml-1.5 text-content-secondary">
{opt.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>{" "}
{/* ── Tool governance row ── */}
<hr className="!my-2 border-0 border-t border-solid border-border" />
<div className="grid items-start gap-5 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="h-9 text-[13px]"
{...form.getFieldProps("toolAllowList")}
placeholder="tool1, tool2"
disabled={isDisabled}
/>
</Field>
<Field
label="Tool Deny List"
htmlFor={`${formId}-deny-list`}
description="Comma-separated names to block."
>
<Input
id={`${formId}-deny-list`}
className="h-9 text-[13px]"
{...form.getFieldProps("toolDenyList")}
placeholder="tool3, tool4"
disabled={isDisabled}
/>
</Field>
</div>
</div>
{/* Footer — pushed to bottom, matches ProviderForm */}
<div className="mt-auto pt-6">
<hr className="mb-4 border-0 border-t border-solid border-border" />
{confirmingDelete && server ? (
<div className="flex items-center gap-3">
<p className="m-0 flex-1 text-sm text-content-secondary">
Are you sure? This action is irreversible.
</p>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="lg"
type="button"
onClick={() => setConfirmingDelete(false)}
disabled={isDisabled}
>
Cancel
</Button>
<Button
variant="destructive"
size="lg"
type="button"
disabled={isDisabled}
onClick={() => void onDelete(server.id)}
>
{isDeleting && <Spinner className="h-4 w-4" loading />}
Delete server
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
{isEditing ? (
<Button
variant="outline"
size="lg"
type="button"
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
disabled={isDisabled}
onClick={() => setConfirmingDelete(true)}
>
Delete
</Button>
) : (
<div />
)}
<Button size="lg" type="submit" disabled={!canSubmit}>
{isSaving && <Spinner className="h-4 w-4" loading />}
{isEditing ? "Save changes" : "Create server"}
</Button>
</div>
)}
</div>
</form>
</div>
);
};
// ── Main Panel ─────────────────────────────────────────────────
interface MCPServerAdminPanelProps {
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
}
export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
sectionLabel,
sectionDescription,
sectionBadge,
}) => {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const serverId = searchParams.get("server");
const serversQuery = useQuery(mcpServerConfigs());
const createMut = useMutation(createMCPServerConfigMutation(queryClient));
const updateMut = useMutation(updateMCPServerConfigMutation(queryClient));
const deleteMut = useMutation(deleteMCPServerConfigMutation(queryClient));
const servers = useMemo(
() =>
(serversQuery.data ?? [])
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
[serversQuery.data],
);
const editingServer = useMemo(
() =>
serverId && serverId !== "new"
? (servers.find((s) => s.id === serverId) ?? null)
: null,
[serverId, servers],
);
const isFormView = serverId !== null;
const isCreating = serverId === "new";
const handleSave = useCallback(
async (req: TypesGen.CreateMCPServerConfigRequest, id?: string) => {
try {
if (id) {
const updateReq: TypesGen.UpdateMCPServerConfigRequest = {
...req,
tool_allow_list: req.tool_allow_list
? [...req.tool_allow_list]
: undefined,
tool_deny_list: req.tool_deny_list
? [...req.tool_deny_list]
: undefined,
};
await updateMut.mutateAsync({ id, req: updateReq });
} else {
await createMut.mutateAsync(req);
}
setSearchParams({});
} catch {
// Error surfaced via mutation error state.
}
},
[createMut, updateMut, setSearchParams],
);
const handleDelete = useCallback(
async (id: string) => {
try {
await deleteMut.mutateAsync(id);
setSearchParams({});
} catch {
// Error surfaced via mutation error state.
}
},
[deleteMut, setSearchParams],
);
if (serversQuery.isLoading) {
return <Spinner loading className="h-4 w-4" />;
}
return (
<div className="flex min-h-full flex-col space-y-3">
{!isFormView ? (
<ServerList
servers={servers}
onSelect={(server) => setSearchParams({ server: server.id })}
onAdd={() => setSearchParams({ server: "new" })}
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
sectionBadge={sectionBadge}
/>
) : (
<ServerForm
key={serverId}
server={isCreating ? null : editingServer}
isSaving={createMut.isPending || updateMut.isPending}
isDeleting={deleteMut.isPending}
onSave={handleSave}
onDelete={handleDelete}
onBack={() => setSearchParams({})}
/>
)}
{serversQuery.isError && <ErrorAlert error={serversQuery.error} />}
{createMut.error && <ErrorAlert error={createMut.error} />}
{updateMut.error && <ErrorAlert error={updateMut.error} />}
{deleteMut.error && <ErrorAlert error={deleteMut.error} />}
</div>
);
};
@@ -52,6 +52,7 @@ import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
import { InsightsContent } from "./InsightsContent";
import { LimitsTab } from "./LimitsTab";
import { MCPServerAdminPanel } from "./MCPServerAdminPanel";
import { SectionHeader } from "./SectionHeader";
const AdminBadge: FC = () => (
@@ -634,6 +635,13 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
sectionBadge={<AdminBadge />}
/>
)}
{activeSection === "mcp-servers" && canManageChatModelConfigs && (
<MCPServerAdminPanel
sectionLabel="MCP Servers"
sectionDescription="Configure external MCP servers that provide additional tools for AI chat sessions."
sectionBadge={<AdminBadge />}
/>
)}
{activeSection === "limits" && canManageChatModelConfigs && (
<LimitsTab />
)}