diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a197ade4c5..6b28f637e0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { + const response = + await this.axios.get(mcpServerConfigsPath); + return response.data; + }; + + createMCPServerConfig = async ( + req: TypesGen.CreateMCPServerConfigRequest, + ): Promise => { + const response = await this.axios.post( + mcpServerConfigsPath, + req, + ); + return response.data; + }; + + updateMCPServerConfig = async ( + id: string, + req: TypesGen.UpdateMCPServerConfigRequest, + ): Promise => { + const response = await this.axios.patch( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, + req, + ); + return response.data; + }; + + deleteMCPServerConfig = async (id: string): Promise => { + await this.axios.delete( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, + ); + }; + getAIBridgeModels = async (options: SearchParamOptions) => { const url = getURLWithSearchParams("/api/v2/aibridge/models", options); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 9779d64cd6..475fc55353 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -631,3 +631,44 @@ export const deleteChatUsageLimitGroupOverride = ( }); }, }); + +// ── MCP Server Configs ─────────────────────────────────────── + +const mcpServerConfigsKey = ["mcp-server-configs"] as const; + +export const mcpServerConfigs = () => ({ + queryKey: mcpServerConfigsKey, + queryFn: (): Promise => 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); + }, +}); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx index 295c8fe7ab..aeb906231f 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx @@ -317,13 +317,13 @@ export const ProviderForm: FC = ({ interface ProviderFieldProps { label: string; - htmlFor: string; + htmlFor?: string; required?: boolean; description?: string; children: React.ReactNode; } -const ProviderField: FC = ({ +export const ProviderField: FC = ({ label, htmlFor, required, diff --git a/site/src/pages/AgentsPage/MCPServerAdminPanel.stories.tsx b/site/src/pages/AgentsPage/MCPServerAdminPanel.stories.tsx new file mode 100644 index 0000000000..f6090a1a8c --- /dev/null +++ b/site/src/pages/AgentsPage/MCPServerAdminPanel.stories.tsx @@ -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 & + Pick, +): 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 = { + 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; + +// ── 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" }, + }), + ); + }, +}; diff --git a/site/src/pages/AgentsPage/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/MCPServerAdminPanel.tsx new file mode 100644 index 0000000000..08c9656762 --- /dev/null +++ b/site/src/pages/AgentsPage/MCPServerAdminPanel.tsx @@ -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 ( +
+ +
+ ); + } + return ( +
+ +
+ ); +}; + +// ── Server List ──────────────────────────────────────────────── + +interface ServerListProps { + servers: readonly TypesGen.MCPServerConfig[]; + onSelect: (server: TypesGen.MCPServerConfig) => void; + onAdd: () => void; + sectionLabel?: string; + sectionDescription?: string; + sectionBadge?: ReactNode; +} + +const ServerList: FC = ({ + servers, + onSelect, + onAdd, + sectionLabel, + sectionDescription, + sectionBadge, +}) => ( + <> + + + Add Server + + } + /> + + {servers.length === 0 ? ( +
+ No MCP servers configured yet. Add a server to get started. +
+ ) : ( +
+ {servers.map((server, i) => ( + + ))} +
+ )} + +); + +// ── Server Form ──────────────────────────────────────────────── + +interface MCPServerFormValues { + displayName: string; + slug: string; + slugTouched: boolean; + description: string; + iconURL: string; + url: string; + transport: string; + authType: string; + oauth2ClientID: string; + oauth2ClientSecret: string; + oauth2SecretTouched: boolean; + oauth2AuthURL: string; + oauth2TokenURL: string; + oauth2Scopes: string; + apiKeyHeader: string; + apiKeyValue: string; + apiKeyTouched: boolean; + availability: string; + enabled: boolean; + 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; + onDelete: (id: string) => Promise; + onBack: () => void; +} + +const ServerForm: FC = ({ + server, + isSaving, + isDeleting, + onSave, + onDelete, + onBack, +}) => { + const formId = useId(); + const isEditing = server !== null; + const [confirmingDelete, setConfirmingDelete] = useState(false); + + const form = useFormik({ + initialValues: buildInitialValues(server), + onSubmit: async (values) => { + const effectiveOAuth2Secret = + values.oauth2SecretTouched && + values.oauth2ClientSecret !== SECRET_PLACEHOLDER + ? values.oauth2ClientSecret + : undefined; + const effectiveApiKeyValue = + values.apiKeyTouched && values.apiKeyValue !== SECRET_PLACEHOLDER + ? values.apiKeyValue + : undefined; + + const req: TypesGen.CreateMCPServerConfigRequest = { + display_name: values.displayName.trim(), + slug: values.slug.trim(), + description: values.description.trim(), + icon_url: values.iconURL.trim(), + url: values.url.trim(), + transport: values.transport, + auth_type: values.authType, + availability: values.availability, + enabled: values.enabled, + ...(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 ( +
+ {/* Back */} + + + {/* Header with icon + editable name + enabled toggle */} +
+ + { + form.setFieldValue("displayName", e.target.value); + if (!form.values.slugTouched) { + form.setFieldValue("slug", slugify(e.target.value)); + } + }} + disabled={isDisabled} + className="m-0 min-w-0 flex-1 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0" + placeholder="Server display name" + aria-label="Display Name" + /> + + + + { + form.setFieldValue("enabled", v); + }} + aria-label="Enabled" + disabled={isDisabled} + /> + + + + {form.values.enabled ? "Disable" : "Enable"} this server + + +
+
+ +
+
+ {" "} + {/* ── Identity row: slug + description side by side ── */} +
+ {" "} + + { + form.setFieldValue("slugTouched", true); + form.setFieldValue("slug", e.target.value); + }} + placeholder="e.g. sentry" + disabled={isDisabled} + /> + + + + +
+ + { + form.setFieldValue("iconURL", e.target.value); + }} + onPickEmoji={(value) => { + form.setFieldValue("iconURL", value); + }} + disabled={isDisabled} + /> + + {/* ── Connection row: URL + transport side by side ── */} +
+
+ + + + + + + +
+ {/* ── Authentication ── */} +
+ + + + {form.values.authType === "oauth2" && ( +
+

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

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

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

+ )} + {form.values.customHeaders.map((header, index) => ( +
+
+ { + form.setFieldValue("customHeadersTouched", true); + const updated = [...form.values.customHeaders]; + updated[index] = { + ...updated[index], + key: e.target.value, + }; + form.setFieldValue("customHeaders", updated); + }} + placeholder="Header name" + disabled={isDisabled} + aria-label={`Header ${index + 1} name`} + /> + { + form.setFieldValue("customHeadersTouched", true); + const updated = [...form.values.customHeaders]; + updated[index] = { + ...updated[index], + value: e.target.value, + }; + form.setFieldValue("customHeaders", updated); + }} + placeholder="Header value" + disabled={isDisabled} + aria-label={`Header ${index + 1} value`} + /> +
+ +
+ ))} + +
+ )} + {/* ── Availability ── */} +
+ + + {" "} + {/* ── Tool governance row ── */} +
+
+ + + + + + + +
+
+ + {/* Footer — pushed to bottom, matches ProviderForm */} +
+
+ {confirmingDelete && server ? ( +
+

+ Are you sure? This action is irreversible. +

+
+ + +
+
+ ) : ( +
+ {isEditing ? ( + + ) : ( +
+ )} + +
+ )} +
+ +
+ ); +}; + +// ── Main Panel ───────────────────────────────────────────────── + +interface MCPServerAdminPanelProps { + sectionLabel?: string; + sectionDescription?: string; + sectionBadge?: ReactNode; +} + +export const MCPServerAdminPanel: FC = ({ + 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 ; + } + + return ( +
+ {!isFormView ? ( + setSearchParams({ server: server.id })} + onAdd={() => setSearchParams({ server: "new" })} + sectionLabel={sectionLabel} + sectionDescription={sectionDescription} + sectionBadge={sectionBadge} + /> + ) : ( + setSearchParams({})} + /> + )} + + {serversQuery.isError && } + {createMut.error && } + {updateMut.error && } + {deleteMut.error && } +
+ ); +}; diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index d122b7e272..afbc45dcdb 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -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 = ({ sectionBadge={} /> )} + {activeSection === "mcp-servers" && canManageChatModelConfigs && ( + } + /> + )} {activeSection === "limits" && canManageChatModelConfigs && ( )}