diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8976bf901c..9a49f182e4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3845,6 +3845,32 @@ class ExperimentalApiMethods { ); }; + getMCPServerUserHeaderValues = async ( + id: string, + ): Promise => { + const response = await this.axios.get( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`, + ); + return response.data; + }; + + updateMCPServerUserHeaderValues = async ( + id: string, + req: TypesGen.UpdateMCPServerUserHeaderValuesRequest, + ): Promise => { + const response = await this.axios.put( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`, + req, + ); + return response.data; + }; + + deleteMCPServerUserHeaderValues = async (id: string): Promise => { + await this.axios.delete( + `${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`, + ); + }; + getChatCostSummary = async ( user = "me", params?: ChatCostDateParams, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 0da5ec2197..e3481e3625 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2037,6 +2037,49 @@ export const deleteMCPServerConfig = (queryClient: QueryClient) => ({ }, }); +const mcpServerUserHeaderValuesKey = (id: string) => + ["mcp-server-user-header-values", id] as const; + +export const mcpServerUserHeaderValues = (id: string) => ({ + queryKey: mcpServerUserHeaderValuesKey(id), + queryFn: (): Promise => + API.experimental.getMCPServerUserHeaderValues(id), +}); + +type UpdateMCPServerUserHeaderValuesArgs = { + id: string; + req: TypesGen.UpdateMCPServerUserHeaderValuesRequest; +}; + +export const updateMCPServerUserHeaderValues = (queryClient: QueryClient) => ({ + mutationFn: ({ id, req }: UpdateMCPServerUserHeaderValuesArgs) => + API.experimental.updateMCPServerUserHeaderValues(id, req), + onSuccess: async ( + _data: unknown, + variables: UpdateMCPServerUserHeaderValuesArgs, + ) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: mcpServerUserHeaderValuesKey(variables.id), + }), + invalidateMCPServerConfigQueries(queryClient), + ]); + }, +}); + +export const deleteMCPServerUserHeaderValues = (queryClient: QueryClient) => ({ + mutationFn: (id: string) => + API.experimental.deleteMCPServerUserHeaderValues(id), + onSuccess: async (_data: unknown, id: string) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: mcpServerUserHeaderValuesKey(id), + }), + invalidateMCPServerConfigQueries(queryClient), + ]); + }, +}); + type SetChatUserRoleVariables = { chatId: string; userId: string; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPage.tsx b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPage.tsx new file mode 100644 index 0000000000..0273abd24d --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPage.tsx @@ -0,0 +1,147 @@ +import { type FC, useCallback, useEffect, useMemo, useState } from "react"; +import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; +import { + deleteMCPServerUserHeaderValues, + mcpServerConfigs, + mcpServerConfigsKey, + mcpServerUserHeaderValues, + updateMCPServerUserHeaderValues, +} from "#/api/queries/chats"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + AgentSettingsUserMCPServersPageView, + filterUserConfigurableServers, +} from "./AgentSettingsUserMCPServersPageView"; + +const AgentSettingsUserMCPServersPage: FC = () => { + const queryClient = useQueryClient(); + + const serversQuery = useQuery(mcpServerConfigs()); + + // Only servers with custom_headers that require user-supplied values need + // the per-user header status fetch. OAuth2 status comes from the existing + // `auth_connected` field on the server config. + const visibleServers = useMemo( + () => filterUserConfigurableServers(serversQuery.data ?? []), + [serversQuery.data], + ); + + const customHeaderServers = useMemo( + () => visibleServers.filter((s) => s.auth_type === "custom_headers"), + [visibleServers], + ); + + const headerQueries = useQueries({ + queries: customHeaderServers.map((server) => ({ + ...mcpServerUserHeaderValues(server.id), + })), + }); + + const headerValueStatus = useMemo(() => { + const out: Record> = {}; + customHeaderServers.forEach((server, index) => { + const data = headerQueries[index]?.data; + if (data) { + out[server.id] = data.has_values ?? {}; + } + }); + return out; + }, [customHeaderServers, headerQueries]); + + const loadingHeaderStatusIds = useMemo(() => { + const ids = new Set(); + customHeaderServers.forEach((server, index) => { + if (headerQueries[index]?.isLoading) { + ids.add(server.id); + } + }); + return ids; + }, [customHeaderServers, headerQueries]); + + const updateMutation = useMutation( + updateMCPServerUserHeaderValues(queryClient), + ); + const deleteMutation = useMutation( + deleteMCPServerUserHeaderValues(queryClient), + ); + + const onSaveHeaderValues = useCallback( + async ( + server: TypesGen.MCPServerConfig, + values: Record, + ) => { + await updateMutation.mutateAsync({ id: server.id, req: { values } }); + }, + [updateMutation], + ); + + const onClearHeaderValues = useCallback( + async (server: TypesGen.MCPServerConfig) => { + await deleteMutation.mutateAsync(server.id); + }, + [deleteMutation], + ); + + // Reset the inline error state from the previous configure-dialog + // session so the next user does not see a stale failure banner. + const onResetSaveHeaderValuesError = useCallback(() => { + updateMutation.reset(); + deleteMutation.reset(); + }, [updateMutation, deleteMutation]); + + // OAuth2 connect: open the experimental MCP OAuth2 connect URL in a popup + // and refresh the server list when the popup posts back. + const [oauth2PopupRef, setOAuth2PopupRef] = useState(null); + + useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.origin !== location.origin) return; + if ( + event.data?.type === "mcp-oauth2-complete" && + typeof event.data.serverID === "string" + ) { + setOAuth2PopupRef(null); + void queryClient.invalidateQueries({ queryKey: mcpServerConfigsKey }); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [queryClient]); + + useEffect(() => { + if (!oauth2PopupRef) return; + const interval = setInterval(() => { + if (oauth2PopupRef.closed) { + setOAuth2PopupRef(null); + } + }, 500); + return () => clearInterval(interval); + }, [oauth2PopupRef]); + + const onConnectOAuth2 = useCallback((server: TypesGen.MCPServerConfig) => { + const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`; + const popup = window.open(connectUrl, "_blank", "width=900,height=600"); + if (popup) { + setOAuth2PopupRef(popup); + } + }, []); + + return ( + + ); +}; + +export default AgentSettingsUserMCPServersPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.stories.tsx new file mode 100644 index 0000000000..e3815df228 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.stories.tsx @@ -0,0 +1,305 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + AgentSettingsUserMCPServersPageView, + type AgentSettingsUserMCPServersPageViewProps, +} from "./AgentSettingsUserMCPServersPageView"; + +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, + custom_headers_user_keys: overrides.custom_headers_user_keys ?? [], + custom_headers_user_key_descriptions: + overrides.custom_headers_user_key_descriptions ?? {}, + tool_allow_list: overrides.tool_allow_list ?? [], + tool_deny_list: overrides.tool_deny_list ?? [], + availability: overrides.availability ?? "default_on", + enabled: overrides.enabled ?? true, + model_intent: overrides.model_intent ?? false, + allow_in_plan_mode: overrides.allow_in_plan_mode ?? false, + forward_coder_headers: overrides.forward_coder_headers ?? false, + created_at: overrides.created_at ?? now, + updated_at: overrides.updated_at ?? now, + auth_connected: overrides.auth_connected ?? false, +}); + +const meta: Meta = { + title: "pages/AgentsPage/AgentSettingsUserMCPServersPageView", + component: AgentSettingsUserMCPServersPageView, + args: { + servers: [], + isLoadingServers: false, + serversError: null, + headerValueStatus: {}, + loadingHeaderStatusIds: new Set(), + onConnectOAuth2: fn(), + onSaveHeaderValues: fn(async () => undefined), + onClearHeaderValues: fn(async () => undefined), + isSavingHeaderValues: false, + isClearingHeaderValues: false, + saveHeaderValuesError: null, + } satisfies AgentSettingsUserMCPServersPageViewProps, +}; + +export default meta; +type Story = StoryObj; + +// ── Stories ──────────────────────────────────────────────────── + +export const NoServersRequireAction: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-none", + display_name: "Quiet Server", + slug: "quiet", + auth_type: "none", + }), + ], + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect( + body.getByText(/No MCP servers require your action/i), + ).toBeInTheDocument(); + }); + }, +}; + +export const Loading: Story = { + args: { + servers: undefined, + isLoadingServers: true, + }, +}; + +export const ServersError: Story = { + args: { + servers: undefined, + serversError: new Error("Boom"), + }, +}; + +export const OAuth2NeedsConnect: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-github", + display_name: "GitHub MCP", + slug: "github", + auth_type: "oauth2", + auth_connected: false, + }), + ], + }, + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { name: /Connect/i }), + ); + expect(args.onConnectOAuth2).toHaveBeenCalledTimes(1); + }, +}; + +export const OAuth2Connected: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-github", + display_name: "GitHub MCP", + slug: "github", + auth_type: "oauth2", + auth_connected: true, + }), + ], + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect(body.getByText(/Signed in/i)).toBeInTheDocument(); + }); + }, +}; + +export const CustomHeadersNotConfigured: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-honcho", + display_name: "Honcho", + slug: "honcho", + auth_type: "custom_headers", + has_custom_headers: true, + custom_headers_user_keys: ["X-Honcho-User"], + auth_connected: false, + }), + ], + headerValueStatus: { + "mcp-honcho": { "X-Honcho-User": false }, + }, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect(body.getByText(/Action required/i)).toBeInTheDocument(); + }); + }, +}; + +export const CustomHeadersAllSet: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-honcho", + display_name: "Honcho", + slug: "honcho", + auth_type: "custom_headers", + has_custom_headers: true, + custom_headers_user_keys: ["X-Honcho-User"], + auth_connected: true, + }), + ], + headerValueStatus: { + "mcp-honcho": { "X-Honcho-User": true }, + }, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect(body.getByText(/Connected/i)).toBeInTheDocument(); + }); + }, +}; + +export const ConfigureSavesValues: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-honcho", + display_name: "Honcho", + slug: "honcho", + auth_type: "custom_headers", + has_custom_headers: true, + custom_headers_user_keys: ["X-Honcho-User", "X-Honcho-Session"], + custom_headers_user_key_descriptions: { + "X-Honcho-User": + "Your Honcho user ID, copy it from your Honcho profile.", + }, + auth_connected: false, + }), + ], + headerValueStatus: { + "mcp-honcho": { "X-Honcho-User": false, "X-Honcho-Session": false }, + }, + }, + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await body.findByRole("button", { name: /Configure/i }), + ); + + // The admin-supplied description shows above the matching input. + expect(await body.findByText(/Your Honcho user ID/i)).toBeInTheDocument(); + + const userInput = await body.findByLabelText(/X-Honcho-User value/i); + await userEvent.type(userInput, "user-jwt-abc"); + + const sessionInput = body.getByLabelText(/X-Honcho-Session value/i); + await userEvent.type(sessionInput, "session-jwt-def"); + + await userEvent.click(body.getByRole("button", { name: /^Save$/i })); + + await waitFor(() => { + expect(args.onSaveHeaderValues).toHaveBeenCalledTimes(1); + }); + const call = (args.onSaveHeaderValues as ReturnType).mock + .calls[0]; + expect(call?.[1]).toEqual({ + "X-Honcho-User": "user-jwt-abc", + "X-Honcho-Session": "session-jwt-def", + }); + }, +}; + +export const ConfigureClearsValues: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-honcho", + display_name: "Honcho", + slug: "honcho", + auth_type: "custom_headers", + has_custom_headers: true, + custom_headers_user_keys: ["X-Honcho-User"], + auth_connected: true, + }), + ], + headerValueStatus: { + "mcp-honcho": { "X-Honcho-User": true }, + }, + }, + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click(await body.findByRole("button", { name: /Edit/i })); + + // Clear button appears because at least one value is set. + const clearButton = await body.findByRole("button", { name: /Clear all/i }); + await userEvent.click(clearButton); + + await waitFor(() => { + expect(args.onClearHeaderValues).toHaveBeenCalledTimes(1); + }); + }, +}; + +export const ConfigureSaveError: Story = { + args: { + servers: [ + createServerConfig({ + id: "mcp-honcho", + display_name: "Honcho", + slug: "honcho", + auth_type: "custom_headers", + has_custom_headers: true, + custom_headers_user_keys: ["X-Honcho-User"], + auth_connected: false, + }), + ], + headerValueStatus: { + "mcp-honcho": { "X-Honcho-User": false }, + }, + saveHeaderValuesError: "Server returned 500", + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { name: /Configure/i }), + ); + await waitFor(() => { + expect(body.getByText(/Server returned 500/i)).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.tsx new file mode 100644 index 0000000000..fb60978f71 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsUserMCPServersPageView.tsx @@ -0,0 +1,448 @@ +import { ServerIcon } from "lucide-react"; +import { type FC, useEffect, useId, useMemo, useState } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; +import { Input } from "#/components/Input/Input"; +import { Loader } from "#/components/Loader/Loader"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { cn } from "#/utils/cn"; + +// ── Helpers ──────────────────────────────────────────────────── + +/** + * A server is shown to the user when it is enabled AND requires + * per-user action: either OAuth2 sign-in, or admin-marked + * custom-header keys for the user to supply. + */ +export const filterUserConfigurableServers = ( + servers: readonly TypesGen.MCPServerConfig[], +): TypesGen.MCPServerConfig[] => + servers.filter( + (s) => + s.enabled && + (s.auth_type === "oauth2" || + (s.auth_type === "custom_headers" && + (s.custom_headers_user_keys?.length ?? 0) > 0)), + ); + +// ── Server icon ──────────────────────────────────────────────── + +const MCPServerIcon: FC<{ iconUrl: string; name: string }> = ({ + iconUrl, + name, +}) => { + if (iconUrl) { + return ( + + ); + } + return ( +