From 7db77bbefaaa0c014ab9a0d0deeff643ee89226a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 19 Mar 2026 14:53:35 -0400 Subject: [PATCH] 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. --- site/src/api/api.ts | 35 + site/src/api/queries/chats.ts | 41 + .../ChatModelAdminPanel/ProviderForm.tsx | 4 +- .../MCPServerAdminPanel.stories.tsx | 688 ++++++++++++ .../pages/AgentsPage/MCPServerAdminPanel.tsx | 981 ++++++++++++++++++ .../pages/AgentsPage/SettingsPageContent.tsx | 8 + 6 files changed, 1755 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/AgentsPage/MCPServerAdminPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/MCPServerAdminPanel.tsx 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 && ( )}