From b3c6421bfe2b1b307f22f0171b88d211e1214ff8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:29:20 +0000 Subject: [PATCH 01/51] feat(site): add organization picker to MCP server settings pages Multi-org deployments could only manage the default organization's MCP servers through the UI. The list and add pages now carry an organization picker (shown only when more than one organization is visible) persisted in an org search param, and the update page loads servers by ID so servers from any organization can be edited. Single-organization deployments see no change. --- site/src/api/api.ts | 9 ++ site/src/api/queries/chats.ts | 6 + site/src/modules/dashboard/useDashboard.ts | 4 - .../AddMCPServerPage/AddMCPServerPage.tsx | 30 +++- .../AddMCPServerPageView.stories.tsx | 4 + .../AddMCPServerPage/AddMCPServerPageView.tsx | 14 ++ .../MCPServersPage/MCPServersPage.stories.tsx | 138 +++++++++++++++++- .../MCPServersPage/MCPServersPage.tsx | 23 ++- .../MCPServersPageView.stories.tsx | 23 ++- .../MCPServersPage/MCPServersPageView.tsx | 17 ++- .../UpdateMCPServerPage.tsx | 34 ++--- .../components/OrganizationPicker.tsx | 42 ++++++ .../MCPServersPage/organizationParam.ts | 21 +++ 13 files changed, 322 insertions(+), 43 deletions(-) create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c8a54b696bf..51b33d5b432 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3921,6 +3921,15 @@ class ExperimentalApiMethods { return response.data; }; + getMCPServerConfig = async ( + id: string, + ): Promise => { + const response = await this.axios.get( + mcpServerConfigPath(id), + ); + return response.data; + }; + createMCPServerConfig = async ( organization: string, req: TypesGen.CreateMCPServerConfigRequest, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index d9f347d9142..5ceaa881cfb 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2336,6 +2336,12 @@ export const mcpServerConfigs = (organization: string) => ({ API.experimental.getMCPServerConfigs(organization), }); +export const mcpServerConfig = (id: string) => ({ + queryKey: [...mcpServersKey, "detail", id] as const, + queryFn: (): Promise => + API.experimental.getMCPServerConfig(id), +}); + const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { await queryClient.invalidateQueries({ queryKey: mcpServersKey }); }; diff --git a/site/src/modules/dashboard/useDashboard.ts b/site/src/modules/dashboard/useDashboard.ts index 9eee67ca3c7..ebee00fba1e 100644 --- a/site/src/modules/dashboard/useDashboard.ts +++ b/site/src/modules/dashboard/useDashboard.ts @@ -13,10 +13,6 @@ export const useDashboard = (): DashboardValue => { return context; }; -export const getDefaultOrganizationId = ( - organizations: DashboardValue["organizations"], -): string => organizations.find((org) => org.is_default)?.id ?? ""; - export const getDefaultOrganizationName = ( organizations: DashboardValue["organizations"], ): string => organizations.find((org) => org.is_default)?.name ?? ""; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 11148d46125..9cda735c9c8 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -1,32 +1,46 @@ import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; -import { useNavigate } from "react-router"; +import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { createMCPServerConfig } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { - getDefaultOrganizationId, - useDashboard, -} from "#/modules/dashboard/useDashboard"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { + mcpServersPath, + orgSearchParam, + selectOrganization, +} from "../organizationParam"; import AddMCPServerPageView from "./AddMCPServerPageView"; const AddMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationId(organizations); + const [searchParams, setSearchParams] = useSearchParams(); + const organization = selectOrganization( + organizations, + searchParams.get(orgSearchParam), + ); const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( - createMCPServerConfig(queryClient, organization), + createMCPServerConfig(queryClient, organization?.id ?? ""), ); return ( void navigate("/ai/settings/mcp-servers")} + organizations={organizations} + organization={organization} + onSelectOrganization={(org) => { + setSearchParams((params) => { + params.set(orgSearchParam, org.name); + return params; + }); + }} + onCancel={() => void navigate(mcpServersPath(organization))} onCreateServer={async (req) => { try { const server = await createMutation.mutateAsync(req); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 8394af84082..8d1201c418f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -3,6 +3,7 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { MockMCPServerConfig } from "#/testHelpers/chatEntities"; +import { MockDefaultOrganization } from "#/testHelpers/entities"; import AddMCPServerPageView from "./AddMCPServerPageView"; const meta: Meta = { @@ -10,6 +11,9 @@ const meta: Meta = { component: AddMCPServerPageView, args: { isSaving: false, + organizations: [MockDefaultOrganization], + organization: MockDefaultOrganization, + onSelectOrganization: fn(), onCreateServer: fn( async (req: TypesGen.CreateMCPServerConfigRequest) => ({ ...MockMCPServerConfig, ...req }) as TypesGen.MCPServerConfig, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index bb3046e2002..68f0588c284 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -2,9 +2,13 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { pageTitle } from "#/utils/page"; import { MCPServerForm } from "../components/MCPServerForm"; +import { OrganizationPicker } from "../components/OrganizationPicker"; interface AddMCPServerPageViewProps { isSaving: boolean; + organizations: readonly TypesGen.Organization[]; + organization: TypesGen.Organization | undefined; + onSelectOrganization: (organization: TypesGen.Organization) => void; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -13,12 +17,22 @@ interface AddMCPServerPageViewProps { const AddMCPServerPageView: FC = ({ isSaving, + organizations, + organization, + onSelectOrganization, onCreateServer, onCancel, }) => { return ( <> Codestin Search App + { + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + async (organization) => + organization === MockOrganization2.id + ? [MockOrganization2MCPServer] + : [MockCoderMCPServer], + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Organization" })); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { + name: MockOrganization2.display_name, + }), + ); + await expect(await canvas.findByText("Org2 Search")).toBeVisible(); + expect(canvas.queryByText("Coder")).not.toBeInTheDocument(); + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockOrganization2.id, + ); }, }; @@ -84,7 +134,83 @@ export const AddUsesDefaultOrganization: Story = { }, }; -export const UpdateLoadsDefaultOrganization: Story = { +export const AddToSelectedOrganization: Story = { + render: () => , + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/mcp-servers/add", + searchParams: { org: MockOrganization2.name }, + }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockOrganization2MCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { name: "Organization" }), + ).toHaveTextContent(MockOrganization2.display_name); + await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); + await userEvent.type( + canvas.getByLabelText(/server url/i), + "https://api.githubcopilot.com/mcp/", + ); + await userEvent.click(canvas.getByRole("button", { name: "Add server" })); + await waitFor(() => { + expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( + MockOrganization2.id, + expect.objectContaining({ display_name: "GitHub" }), + ); + }); + }, +}; + +export const AddSwitchesOrganization: Story = { + render: () => , + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockOrganization2MCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); + await userEvent.type( + canvas.getByLabelText(/server url/i), + "https://api.githubcopilot.com/mcp/", + ); + await userEvent.click(canvas.getByRole("button", { name: "Organization" })); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { + name: MockOrganization2.display_name, + }), + ); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue("GitHub"); + await userEvent.click(canvas.getByRole("button", { name: "Add server" })); + await waitFor(() => { + expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( + MockOrganization2.id, + expect.objectContaining({ display_name: "GitHub" }), + ); + }); + }, +}; + +export const UpdateLoadsServerById: Story = { render: () => , parameters: { reactRouter: reactRouterParameters({ @@ -93,15 +219,15 @@ export const UpdateLoadsDefaultOrganization: Story = { }), }, beforeEach: () => { - spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( MockCoderMCPServer, - ]); + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => { - expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( - MockDefaultOrganization.id, + expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith( + MockCoderMCPServer.id, ); }); await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder"); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 75c43196d9b..ae8d88a8130 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -1,21 +1,24 @@ import type { FC } from "react"; import { useQuery } from "react-query"; +import { useSearchParams } from "react-router"; import { mcpServerConfigs } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { - getDefaultOrganizationId, - useDashboard, -} from "#/modules/dashboard/useDashboard"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; import MCPServersPageView from "./MCPServersPageView"; +import { orgSearchParam, selectOrganization } from "./organizationParam"; const MCPServersPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationId(organizations); + const [searchParams, setSearchParams] = useSearchParams(); + const organization = selectOrganization( + organizations, + searchParams.get(orgSearchParam), + ); const serversQuery = useQuery({ - ...mcpServerConfigs(organization), + ...mcpServerConfigs(organization?.id ?? ""), enabled: Boolean(organization), }); const servers = (serversQuery.data ?? []).toSorted((a, b) => @@ -29,6 +32,14 @@ const MCPServersPage: FC = () => { isLoading={serversQuery.isLoading} error={serversQuery.error} servers={servers} + organizations={organizations} + organization={organization} + onSelectOrganization={(org) => { + setSearchParams((params) => { + params.set(orgSearchParam, org.name); + return params; + }); + }} /> ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx index f7bdda942d2..6a66cc6c14b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, fn, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { + MockDefaultOrganization, + MockOrganization2, +} from "#/testHelpers/entities"; import MCPServersPageView from "./MCPServersPageView"; import { MockCoderMCPServer, @@ -21,6 +25,9 @@ const meta: Meta = { MockImageMCPServer, MockMemoryMCPServer, ], + organizations: [MockDefaultOrganization], + organization: MockDefaultOrganization, + onSelectOrganization: fn(), }, parameters: { // TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude. @@ -73,6 +80,20 @@ export const Empty: Story = { }, }; +export const MultipleOrganizations: Story = { + args: { + organizations: [MockDefaultOrganization, MockOrganization2], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const picker = canvas.getByRole("button", { name: "Organization" }); + await expect(picker).toBeVisible(); + await expect(picker).toHaveTextContent( + MockDefaultOrganization.display_name, + ); + }, +}; + export const LoadError: Story = { args: { error: new Error("Failed to load MCP servers"), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index e64cea7302b..c08807cdf3c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -19,20 +19,28 @@ import { import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { MCPServerRow } from "./components/MCPServerRow"; +import { OrganizationPicker } from "./components/OrganizationPicker"; +import { addMCPServerPath } from "./organizationParam"; interface MCPServersPageViewProps { isLoading: boolean; error: unknown; servers: readonly TypesGen.MCPServerConfig[]; + organizations: readonly TypesGen.Organization[]; + organization: TypesGen.Organization | undefined; + onSelectOrganization: (organization: TypesGen.Organization) => void; } const MCPServersPageView: FC = ({ isLoading, error, servers, + organizations, + organization, + onSelectOrganization, }) => { const navigate = useNavigate(); - const goToAddServer = () => void navigate("/ai/settings/mcp-servers/add"); + const goToAddServer = () => void navigate(addMCPServerPath(organization)); return (
@@ -50,6 +58,13 @@ const MCPServersPageView: FC = ({ Agents. + {Boolean(error) && (
diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 6289fbcc821..cfd7184a110 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -5,61 +5,61 @@ import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { deleteMCPServerConfig, - mcpServerConfigs, + mcpServerConfig, updateMCPServerConfig, } from "#/api/queries/chats"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { - getDefaultOrganizationId, - useDashboard, -} from "#/modules/dashboard/useDashboard"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; +import { mcpServersPath } from "../organizationParam"; import UpdateMCPServerPageView from "./UpdateMCPServerPageView"; const UpdateMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationId(organizations); const { serverId } = useParams<{ serverId: string }>(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const serversQuery = useQuery({ - ...mcpServerConfigs(organization), - enabled: Boolean(organization), + const serverQuery = useQuery({ + ...mcpServerConfig(serverId ?? ""), + enabled: Boolean(serverId), }); - const server = serversQuery.data?.find((item) => item.id === serverId); - const serverOrganization = server?.organization_id ?? organization; + const server = serverQuery.data; + const serverOrganization = server?.organization_id ?? ""; const updateMutation = useMutation( updateMCPServerConfig(queryClient, serverOrganization), ); const deleteMutation = useMutation( deleteMCPServerConfig(queryClient, serverOrganization), ); + const listPath = mcpServersPath( + organizations.find((org) => org.id === server?.organization_id), + ); return ( {!serverId ? ( - - ) : serversQuery.isLoading ? ( + + ) : serverQuery.isLoading ? ( <> Codestin Search App ) : !server ? ( - + ) : ( void navigate("/ai/settings/mcp-servers")} + onCancel={() => void navigate(listPath)} onUpdateServer={async (id, req) => { try { const updated = await updateMutation.mutateAsync({ id, req }); toast.success(`MCP server "${updated.display_name}" updated.`); - await navigate("/ai/settings/mcp-servers"); + await navigate(listPath); } catch (error) { toast.error( getErrorMessage(error, "Failed to update MCP server."), @@ -70,7 +70,7 @@ const UpdateMCPServerPage: FC = () => { try { await deleteMutation.mutateAsync(id); toast.success(`MCP server "${server.display_name}" deleted.`); - await navigate("/ai/settings/mcp-servers", { replace: true }); + await navigate(listPath, { replace: true }); } catch (error) { toast.error( getErrorMessage(error, "Failed to delete MCP server."), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx new file mode 100644 index 00000000000..5d2bbcdd775 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; +import type { Organization } from "#/api/typesGenerated"; +import { Label } from "#/components/Label/Label"; +import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { cn } from "#/utils/cn"; + +interface OrganizationPickerProps { + id: string; + organizations: readonly Organization[]; + organization: Organization | undefined; + onChange: (organization: Organization) => void; + className?: string; +} + +export const OrganizationPicker: FC = ({ + id, + organizations, + organization, + onChange, + className, +}) => { + if (organizations.length <= 1) { + return null; + } + + return ( +
+ + { + if (org) { + onChange(org); + } + }} + options={organizations} + required + /> +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts new file mode 100644 index 00000000000..d210f32ed56 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts @@ -0,0 +1,21 @@ +import type { Organization } from "#/api/typesGenerated"; + +export const orgSearchParam = "org"; + +export const selectOrganization = ( + organizations: readonly Organization[], + name: string | null, +): Organization | undefined => + organizations.find((org) => org.name === name) ?? + organizations.find((org) => org.is_default) ?? + organizations[0]; + +export const mcpServersPath = (organization: Organization | undefined) => + organization + ? `/ai/settings/mcp-servers?${orgSearchParam}=${encodeURIComponent(organization.name)}` + : "/ai/settings/mcp-servers"; + +export const addMCPServerPath = (organization: Organization | undefined) => + organization + ? `/ai/settings/mcp-servers/add?${orgSearchParam}=${encodeURIComponent(organization.name)}` + : "/ai/settings/mcp-servers/add"; From 37481ea8899d802792a5a71ef69311319e6d34b0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:53:31 +0000 Subject: [PATCH 02/51] fix(site): preserve the organization in the MCP form back link The form header's back link hardcoded the bare list route, silently resetting the list to the default organization after a cancel via the header. Thread the organization-aware list path through the form so the header link and footer cancel land on the same organization. --- .../AddMCPServerPage/AddMCPServerPageView.tsx | 2 ++ .../MCPServersPage/MCPServersPage.stories.tsx | 6 ++++++ .../UpdateMCPServerPage/UpdateMCPServerPage.tsx | 1 + .../UpdateMCPServerPageView.stories.tsx | 1 + .../UpdateMCPServerPage/UpdateMCPServerPageView.tsx | 3 +++ .../MCPServersPage/components/MCPServerForm.tsx | 4 ++++ .../MCPServersPage/components/MCPServerFormHeader.tsx | 8 +++++--- 7 files changed, 22 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 68f0588c284..4cb0ca1f08a 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -3,6 +3,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { pageTitle } from "#/utils/page"; import { MCPServerForm } from "../components/MCPServerForm"; import { OrganizationPicker } from "../components/OrganizationPicker"; +import { mcpServersPath } from "../organizationParam"; interface AddMCPServerPageViewProps { isSaving: boolean; @@ -34,6 +35,7 @@ const AddMCPServerPageView: FC = ({ onChange={onSelectOrganization} /> { expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index cfd7184a110..94bb7f54232 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -52,6 +52,7 @@ const UpdateMCPServerPage: FC = () => { ) : ( void navigate(listPath)} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx index 5ad2d5d7d3e..8124a13f22d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -17,6 +17,7 @@ const meta: Meta = { component: UpdateMCPServerPageView, args: { server: MockCoderMCPServer, + listPath: "/ai/settings/mcp-servers", isSaving: false, isDeleting: false, onUpdateServer, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index 83373b6395b..3be6b8a9075 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -5,6 +5,7 @@ import { MCPServerForm } from "../components/MCPServerForm"; interface UpdateMCPServerPageViewProps { server: TypesGen.MCPServerConfig; + listPath: string; isSaving: boolean; isDeleting: boolean; onUpdateServer: ( @@ -18,6 +19,7 @@ interface UpdateMCPServerPageViewProps { const UpdateMCPServerPageView: FC = ({ server, + listPath, isSaving, isDeleting, onUpdateServer, @@ -31,6 +33,7 @@ const UpdateMCPServerPageView: FC = ({ = ({ server, + listPath, isSaving, isDeleting = false, onCreateServer, @@ -89,6 +92,7 @@ export const MCPServerForm: FC = ({ server={server} title={title} iconUrl={form.values.iconURL} + listPath={listPath} isEditing={isEditing} isDisabled={isDisabled} onRequestDelete={() => setConfirmingDelete(true)} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx index ecbf0ca5f80..bb0cf2c88a9 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormHeader.tsx @@ -20,9 +20,9 @@ import { import { cn } from "#/utils/cn"; import { MCPServerIcon } from "./MCPServerIcon"; -const MCPServerFormBackLink: FC = () => { +const MCPServerFormBackLink: FC<{ to: string }> = ({ to }) => { return ( - +
); From da0504a79f39d7a16bd7ca59c55c8e3081ee8aa9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:39:01 +0000 Subject: [PATCH 10/51] fix(site/src): apply MCP org picker review fixes --- site/src/api/api.ts | 3 +- site/src/api/queries/chats.ts | 9 +- .../AddMCPServerPage/AddMCPServerPage.tsx | 7 +- .../AddMCPServerPage/AddMCPServerPageView.tsx | 2 +- .../MCPServersPage/MCPServersPage.stories.tsx | 90 +++++++++++-------- .../MCPServersPage/MCPServersPage.tsx | 10 +-- .../MCPServersPage/MCPServersPageView.tsx | 2 +- .../UpdateMCPServerPage.tsx | 19 ++-- .../components/OrganizationPicker.tsx | 4 +- .../MCPServersPage/organizationParam.ts | 32 +++---- 10 files changed, 99 insertions(+), 79 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 51b33d5b432..44a854c43d6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3922,10 +3922,11 @@ class ExperimentalApiMethods { }; getMCPServerConfig = async ( + organization: string, id: string, ): Promise => { const response = await this.axios.get( - mcpServerConfigPath(id), + mcpServerConfigPath(organization, id), ); return response.data; }; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 5ceaa881cfb..66decae1523 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2336,10 +2336,13 @@ export const mcpServerConfigs = (organization: string) => ({ API.experimental.getMCPServerConfigs(organization), }); -export const mcpServerConfig = (id: string) => ({ - queryKey: [...mcpServersKey, "detail", id] as const, +export const mcpServerConfigKey = (organization: string, id: string) => + [...mcpServerConfigsKey(organization), "detail", id] as const; + +export const mcpServerConfig = (organization: string, id: string) => ({ + queryKey: mcpServerConfigKey(organization, id), queryFn: (): Promise => - API.experimental.getMCPServerConfig(id), + API.experimental.getMCPServerConfig(organization, id), }); const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 7d11013ab6d..7e42434a693 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -26,7 +26,7 @@ const AddMCPServerPage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( - createMCPServerConfig(queryClient, organization?.id ?? ""), + createMCPServerConfig(queryClient, organization.id), ); return ( @@ -37,8 +37,9 @@ const AddMCPServerPage: FC = () => { organization={organization} onSelectOrganization={(org) => { setSearchParams((params) => { - params.set(orgSearchParam, org.name); - return params; + const next = new URLSearchParams(params); + next.set(orgSearchParam, org.name); + return next; }); }} onCancel={() => void navigate(mcpServersPath(organization))} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 2f7872b86c3..23a5f5c9f16 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -8,7 +8,7 @@ import { mcpServersPath } from "../organizationParam"; interface AddMCPServerPageViewProps { isSaving: boolean; organizations: readonly TypesGen.Organization[]; - organization: TypesGen.Organization | undefined; + organization: TypesGen.Organization; onSelectOrganization: (organization: TypesGen.Organization) => void; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 41002e058f6..cc485529f29 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -1,11 +1,11 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; -import { type QueryClient, useQueryClient } from "react-query"; +import { useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; -import { mcpServerConfig } from "#/api/queries/chats"; +import { mcpServerConfigKey } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import { MockDefaultOrganization, @@ -23,8 +23,6 @@ import { orgSearchParam } from "./organizationParam"; import { MockCoderMCPServer } from "./testFixtures"; import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage"; -let capturedQueryClient: QueryClient | undefined; - const MockOrganization2MCPServer: TypesGen.MCPServerConfig = { ...MockCoderMCPServer, id: "mcp-org2", @@ -33,9 +31,34 @@ const MockOrganization2MCPServer: TypesGen.MCPServerConfig = { organization_id: MockOrganization2.id, }; -const mockOrganization2MCPServerQueryKey = mcpServerConfig( +const mockOrganization2MCPServerQueryKey = mcpServerConfigKey( + MockOrganization2.id, MockOrganization2MCPServer.id, -).queryKey; +); + +const RefetchServerDetailProbe: FC = () => { + const queryClient = useQueryClient(); + return ( + + ); +}; + +const withRefetchServerDetailProbe: Decorator = (Story) => ( + <> + + + +); const ListRedirectProbe: FC = () => { const [searchParams] = useSearchParams(); @@ -316,6 +339,7 @@ export const UpdateLoadsServerById: Story = { const canvas = within(canvasElement); await waitFor(() => { expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, MockCoderMCPServer.id, ); }); @@ -362,19 +386,19 @@ export const RowClickCarriesSelectedOrganization: Story = { }, }; -const notFoundError = (() => { +const mockNotFoundError = (() => { const error = mockApiError({ message: "Resource not found" }); return { ...error, response: { ...error.response, status: 404 } }; })(); -export const UpdateNotFoundRedirectsToSelectedOrganization: Story = { +export const UpdateWrongOrganizationRedirectsToSelectedList: Story = { render: () => , parameters: { organizations: [MockDefaultOrganization, MockOrganization2], reactRouter: reactRouterParameters({ location: { path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, - searchParams: { [orgSearchParam]: MockOrganization2.name }, + searchParams: { [orgSearchParam]: MockDefaultOrganization.name }, }, routing: [ { path: "/ai/settings/mcp-servers/:serverId", useStoryElement: true }, @@ -387,25 +411,24 @@ export const UpdateNotFoundRedirectsToSelectedOrganization: Story = { }, beforeEach: () => { spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( - notFoundError, + mockNotFoundError, ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - await canvas.findByText(`list-org:${MockOrganization2.name}`), + await canvas.findByText(`list-org:${MockDefaultOrganization.name}`), ).toBeVisible(); + expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockOrganization2MCPServer.id, + ); }, }; export const UpdateCachedNotFoundRedirects: Story = { render: () => , - decorators: [ - (Story) => { - capturedQueryClient = useQueryClient(); - return ; - }, - ], + decorators: [withRefetchServerDetailProbe], parameters: { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ @@ -417,6 +440,7 @@ export const UpdateCachedNotFoundRedirects: Story = { reactRouter: reactRouterParameters({ location: { path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, + searchParams: { [orgSearchParam]: MockOrganization2.name }, }, routing: [ { path: "/ai/settings/mcp-servers/:serverId", useStoryElement: true }, @@ -429,7 +453,7 @@ export const UpdateCachedNotFoundRedirects: Story = { }, beforeEach: () => { spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( - notFoundError, + mockNotFoundError, ); }, play: async ({ canvasElement }) => { @@ -437,13 +461,9 @@ export const UpdateCachedNotFoundRedirects: Story = { await expect(canvas.getByLabelText(/display name/i)).toHaveValue( MockOrganization2MCPServer.display_name, ); - if (!capturedQueryClient) { - throw new Error("query client was not captured by the story decorator"); - } - await capturedQueryClient.refetchQueries({ - queryKey: mockOrganization2MCPServerQueryKey, - exact: true, - }); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch server detail" }), + ); await expect( await canvas.findByText(`list-org:${MockOrganization2.name}`), ).toBeVisible(); @@ -452,12 +472,7 @@ export const UpdateCachedNotFoundRedirects: Story = { export const UpdateRefetchErrorKeepsCachedForm: Story = { render: () => , - decorators: [ - (Story) => { - capturedQueryClient = useQueryClient(); - return ; - }, - ], + decorators: [withRefetchServerDetailProbe], parameters: { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ @@ -469,6 +484,7 @@ export const UpdateRefetchErrorKeepsCachedForm: Story = { reactRouter: reactRouterParameters({ location: { path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, + searchParams: { [orgSearchParam]: MockOrganization2.name }, }, routing: { path: "/ai/settings/mcp-servers/:serverId" }, }), @@ -483,13 +499,9 @@ export const UpdateRefetchErrorKeepsCachedForm: Story = { const displayName = canvas.getByLabelText(/display name/i); await userEvent.clear(displayName); await userEvent.type(displayName, "Edited name"); - if (!capturedQueryClient) { - throw new Error("query client was not captured by the story decorator"); - } - await capturedQueryClient.refetchQueries({ - queryKey: mockOrganization2MCPServerQueryKey, - exact: true, - }); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch server detail" }), + ); const alert = await canvas.findByRole("alert"); await expect( within(alert).getByRole("heading", { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index ae8d88a8130..5cf6f82ac83 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -17,10 +17,7 @@ const MCPServersPage: FC = () => { organizations, searchParams.get(orgSearchParam), ); - const serversQuery = useQuery({ - ...mcpServerConfigs(organization?.id ?? ""), - enabled: Boolean(organization), - }); + const serversQuery = useQuery(mcpServerConfigs(organization.id)); const servers = (serversQuery.data ?? []).toSorted((a, b) => a.display_name.localeCompare(b.display_name), ); @@ -36,8 +33,9 @@ const MCPServersPage: FC = () => { organization={organization} onSelectOrganization={(org) => { setSearchParams((params) => { - params.set(orgSearchParam, org.name); - return params; + const next = new URLSearchParams(params); + next.set(orgSearchParam, org.name); + return next; }); }} /> diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index 2caa569e7e7..d40e3cf40af 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -27,7 +27,7 @@ interface MCPServersPageViewProps { error: unknown; servers: readonly TypesGen.MCPServerConfig[]; organizations: readonly TypesGen.Organization[]; - organization: TypesGen.Organization | undefined; + organization: TypesGen.Organization; onSelectOrganization: (organization: TypesGen.Organization) => void; } diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 184c340f56d..10d45a506b4 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -19,17 +19,26 @@ import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; -import { mcpServersPath, orgSearchParam } from "../organizationParam"; +import { + mcpServersPath, + orgSearchParam, + selectOrganization, +} from "../organizationParam"; import UpdateMCPServerPageView from "./UpdateMCPServerPageView"; const UpdateMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const { serverId } = useParams<{ serverId: string }>(); + const [searchParams] = useSearchParams(); + const organization = selectOrganization( + organizations, + searchParams.get(orgSearchParam), + ); const queryClient = useQueryClient(); const navigate = useNavigate(); const serverQuery = useQuery({ - ...mcpServerConfig(serverId ?? ""), + ...mcpServerConfig(organization.id, serverId ?? ""), enabled: Boolean(serverId), }); const server = serverQuery.data; @@ -46,13 +55,9 @@ const UpdateMCPServerPage: FC = () => { serverQuery.isError && isApiError(serverQuery.error) && serverQuery.error.response.status === 404; - const [searchParams] = useSearchParams(); - // When the detail 404s (deleted or concealed), the navigation-carried - // organization keeps the redirect on the selected organization's list. - const orgName = searchParams.get(orgSearchParam); const listPath = mcpServersPath( organizations.find((org) => org.id === server?.organization_id) ?? - organizations.find((org) => org.name === orgName), + organization, ); return ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index 70fb7ddc182..b64a5447fb1 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -7,7 +7,7 @@ import { cn } from "#/utils/cn"; interface OrganizationPickerProps { id: string; organizations: readonly Organization[]; - organization: Organization | undefined; + organization: Organization; onChange: (organization: Organization) => void; className?: string; disabled?: boolean; @@ -30,7 +30,7 @@ export const OrganizationPicker: FC = ({ { if (org) { onChange(org); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts index 134dcc33bb6..cd637d20df6 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts +++ b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts @@ -5,25 +5,25 @@ export const orgSearchParam = "org"; export const selectOrganization = ( organizations: readonly Organization[], name: string | null, -): Organization | undefined => - organizations.find((org) => org.name === name) ?? - organizations.find((org) => org.is_default) ?? - organizations[0]; +): Organization => { + const organization = + organizations.find((org) => org.name === name) ?? + organizations.find((org) => org.is_default) ?? + organizations[0]; + if (!organization) { + throw new Error("Expected at least one organization"); + } + return organization; +}; -export const mcpServersPath = (organization: Organization | undefined) => - organization - ? `/ai/settings/mcp-servers?${orgSearchParam}=${encodeURIComponent(organization.name)}` - : "/ai/settings/mcp-servers"; +export const mcpServersPath = (organization: Organization) => + `/ai/settings/mcp-servers?${orgSearchParam}=${encodeURIComponent(organization.name)}`; -export const addMCPServerPath = (organization: Organization | undefined) => - organization - ? `/ai/settings/mcp-servers/add?${orgSearchParam}=${encodeURIComponent(organization.name)}` - : "/ai/settings/mcp-servers/add"; +export const addMCPServerPath = (organization: Organization) => + `/ai/settings/mcp-servers/add?${orgSearchParam}=${encodeURIComponent(organization.name)}`; export const updateMCPServerPath = ( serverId: string, - organization: Organization | undefined, + organization: Organization, ) => - organization - ? `/ai/settings/mcp-servers/${serverId}?${orgSearchParam}=${encodeURIComponent(organization.name)}` - : `/ai/settings/mcp-servers/${serverId}`; + `/ai/settings/mcp-servers/${serverId}?${orgSearchParam}=${encodeURIComponent(organization.name)}`; From 22b57fc97c0cd283ebb564cbe5caea74ef46d9b2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:02:25 +0000 Subject: [PATCH 11/51] chore(site/src/pages/AISettingsPage): use the selected organization directly on the MCP update page --- .../UpdateMCPServerPage/UpdateMCPServerPage.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 10d45a506b4..fd3fbfdad67 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -42,12 +42,11 @@ const UpdateMCPServerPage: FC = () => { enabled: Boolean(serverId), }); const server = serverQuery.data; - const serverOrganization = server?.organization_id ?? ""; const updateMutation = useMutation( - updateMCPServerConfig(queryClient, serverOrganization), + updateMCPServerConfig(queryClient, organization.id), ); const deleteMutation = useMutation( - deleteMCPServerConfig(queryClient, serverOrganization), + deleteMCPServerConfig(queryClient, organization.id), ); // A 404 must win over cached data: a refetch failure keeps stale data, // which would otherwise render a form for a deleted or concealed server. @@ -55,10 +54,7 @@ const UpdateMCPServerPage: FC = () => { serverQuery.isError && isApiError(serverQuery.error) && serverQuery.error.response.status === 404; - const listPath = mcpServersPath( - organizations.find((org) => org.id === server?.organization_id) ?? - organization, - ); + const listPath = mcpServersPath(organization); return ( From 08ea27a83725713d35abe3d799f2011b8a9998ed Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:17:27 +0000 Subject: [PATCH 12/51] docs(docs/ai-coder/agents/platform-controls): describe multi-organization MCP management --- docs/ai-coder/agents/platform-controls/mcp-servers.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index b4c6ee7b0aa..aef833e1200 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -7,8 +7,9 @@ servers are injected into or offered to users during chat depending on the availability policy. This is an admin-only feature accessible at **AI Settings** > **Coder Agents** > **MCP servers** -(`/ai/settings/mcp-servers`). The settings page currently manages the default -organization's servers; other organizations are managed through the API. +(`/ai/settings/mcp-servers`). In multi-organization deployments, use the +organization picker to choose which organization's servers to manage. The picker +only appears when the deployment has more than one organization. ## Add an MCP server From b4cbabdd03db16a44339f3944eb5797d3fc30580 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:18:03 +0000 Subject: [PATCH 13/51] fix(site/src/pages/AISettingsPage): include the selected organization in the MCP org picker trigger name --- .../OrganizationAutocomplete.tsx | 3 ++ .../MCPServersPage/MCPServersPage.stories.tsx | 30 +++++++++++++++---- .../MCPServersPageView.stories.tsx | 4 ++- .../components/OrganizationPicker.tsx | 1 + 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 54b698b55e9..d06e3f88283 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -23,6 +23,7 @@ type OrganizationAutocompleteProps = { onChange: (organization: Organization | null) => void; options: readonly Organization[]; id?: string; + ariaLabel?: string; required?: boolean; disabled?: boolean; }; @@ -32,6 +33,7 @@ export const OrganizationAutocomplete: FC = ({ onChange, options, id, + ariaLabel, required, disabled, }) => { @@ -42,6 +44,7 @@ export const OrganizationAutocomplete: FC = ({
)} + {!permissions.editDeploymentConfig && + permissions.viewAnyMCPServerConfigs && ( +
+ + MCP servers + +
+ )} ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 7e42434a693..4d6b7b2925d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -30,7 +30,11 @@ const AddMCPServerPage: FC = () => { ); return ( - + { ); return ( - + Codestin Search App { const listPath = mcpServersPath(organization); return ( - + {!serverId ? ( ) : serverQuery.isLoading ? ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bdcd17f4dc2..7beeb07760c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3366,6 +3366,9 @@ export const MockPermissions: Permissions = { editAnyGroups: true, editAnySettings: true, viewAnyIdpSyncSettings: true, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: true, viewAnyMembers: true, viewAnyAIBridgeInterception: true, viewAnyAIProvider: true, @@ -3403,6 +3406,9 @@ export const MockNoPermissions: Permissions = { editAnyGroups: false, editAnySettings: false, viewAnyIdpSyncSettings: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: false, + updateAnyMCPServerConfig: false, viewAnyMembers: false, viewAnyAIBridgeInterception: false, viewAnyAIProvider: false, From 56df9e4b2229e3f1315bfb182d789347015129d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:10:09 +0000 Subject: [PATCH 22/51] test(site/src): cover org admin access to MCP server settings --- .../MCPServersPage/MCPServersPage.stories.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index f0c4ad1a468..e44931461d0 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -113,6 +113,28 @@ export const ListUsesDefaultOrganization: Story = { }, }; +export const OrgAdminCanViewMCPServers: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + }, +}; + export const ListSwitchesOrganization: Story = { parameters: { organizations: [MockDefaultOrganization, MockOrganization2], From 779c5a125ce0ebea0c0221236a1527ef1620f2a6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:27:39 +0000 Subject: [PATCH 23/51] fix(site): gate MCP settings actions on per-organization permissions --- site/src/modules/permissions/organizations.ts | 28 +++ .../AddMCPServerPage/AddMCPServerPage.tsx | 99 ++++++--- .../MCPServersPage/MCPServersPage.stories.tsx | 4 +- .../MCPServersPage/MCPServersPage.tsx | 82 +++++-- .../MCPServersPageView.stories.tsx | 2 + .../MCPServersPage/MCPServersPageView.tsx | 33 ++- .../UpdateMCPServerPage.tsx | 210 +++++++++++------- .../UpdateMCPServerPageView.tsx | 2 +- .../components/MCPServerRow.tsx | 12 +- site/src/testHelpers/entities.ts | 8 + 10 files changed, 343 insertions(+), 137 deletions(-) diff --git a/site/src/modules/permissions/organizations.ts b/site/src/modules/permissions/organizations.ts index 81ffb2f8502..52111dd1101 100644 --- a/site/src/modules/permissions/organizations.ts +++ b/site/src/modules/permissions/organizations.ts @@ -115,6 +115,34 @@ export const organizationPermissionChecks = (organizationId: string) => }, action: "update", }, + viewMCPServerConfigs: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "read", + }, + createMCPServerConfig: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "create", + }, + updateMCPServerConfig: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "update", + }, + deleteMCPServerConfig: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "delete", + }, }) as const satisfies Record; /** diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 4d6b7b2925d..419f54c88f4 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -1,9 +1,12 @@ import type { FC } from "react"; -import { useMutation, useQueryClient } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { createMCPServerConfig } from "#/api/queries/chats"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -19,14 +22,41 @@ const AddMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const [searchParams, setSearchParams] = useSearchParams(); - const organization = selectOrganization( - organizations, - searchParams.get(orgSearchParam), + const organizationPermissionsQuery = useQuery( + organizationsPermissions( + organizations.map((organization) => organization.id), + ), ); + const authorizedOrganizations = permissions.editDeploymentConfig + ? organizations + : organizations.filter((organization) => { + const organizationPermissions = + organizationPermissionsQuery.data?.[organization.id]; + return ( + organizationPermissions?.viewMCPServerConfigs && + organizationPermissions.createMCPServerConfig + ); + }); + const organization = + authorizedOrganizations.length > 0 + ? selectOrganization( + authorizedOrganizations, + searchParams.get(orgSearchParam), + ) + : undefined; + const organizationPermissions = organization + ? organizationPermissionsQuery.data?.[organization.id] + : undefined; + const canCreate = + permissions.editDeploymentConfig || + Boolean( + organizationPermissions?.viewMCPServerConfigs && + organizationPermissions.createMCPServerConfig, + ); const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( - createMCPServerConfig(queryClient, organization.id), + createMCPServerConfig(queryClient, organization?.id ?? ""), ); return ( @@ -35,28 +65,43 @@ const AddMCPServerPage: FC = () => { permissions.editDeploymentConfig || permissions.createAnyMCPServerConfig } > - { - setSearchParams((params) => { - const next = new URLSearchParams(params); - next.set(orgSearchParam, org.name); - return next; - }); - }} - onCancel={() => void navigate(mcpServersPath(organization))} - onCreateServer={async (req) => { - try { - const server = await createMutation.mutateAsync(req); - toast.success(`MCP server "${server.display_name}" added.`); - await navigate(updateMCPServerPath(server.id, organization)); - } catch (error) { - toast.error(getErrorMessage(error, "Failed to add MCP server.")); - } - }} - /> + {organizationPermissionsQuery.isError ? ( + + ) : !permissions.editDeploymentConfig && + !organizationPermissionsQuery.data ? ( + + ) : ( + + {organization && ( + { + setSearchParams((params) => { + const next = new URLSearchParams(params); + next.set(orgSearchParam, org.name); + return next; + }); + }} + onCancel={() => void navigate(mcpServersPath(organization))} + onCreateServer={async (req) => { + try { + const server = await createMutation.mutateAsync(req); + toast.success(`MCP server "${server.display_name}" added.`); + await navigate(updateMCPServerPath(server.id, organization)); + } catch (error) { + toast.error( + getErrorMessage(error, "Failed to add MCP server."), + ); + } + }} + /> + )} + + )} ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index e44931461d0..ab97b4164d8 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -131,7 +131,9 @@ export const OrgAdminCanViewMCPServers: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(await canvas.findByText("Coder")).toBeVisible(); + await expect( + await canvas.findByRole("cell", { name: "Coder" }), + ).toBeVisible(); }, }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index ecb8b09ad43..a831da48017 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -2,6 +2,9 @@ import type { FC } from "react"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router"; import { mcpServerConfigs } from "#/api/queries/chats"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -13,11 +16,35 @@ const MCPServersPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const [searchParams, setSearchParams] = useSearchParams(); - const organization = selectOrganization( - organizations, - searchParams.get(orgSearchParam), + const organizationPermissionsQuery = useQuery( + organizationsPermissions( + organizations.map((organization) => organization.id), + ), ); - const serversQuery = useQuery(mcpServerConfigs(organization.id)); + const authorizedOrganizations = permissions.editDeploymentConfig + ? organizations + : organizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .viewMCPServerConfigs, + ); + const organization = + authorizedOrganizations.length > 0 + ? selectOrganization( + authorizedOrganizations, + searchParams.get(orgSearchParam), + ) + : undefined; + const organizationPermissions = organization + ? organizationPermissionsQuery.data?.[organization.id] + : undefined; + const canView = + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.viewMCPServerConfigs); + const serversQuery = useQuery({ + ...mcpServerConfigs(organization?.id ?? ""), + enabled: canView, + }); const servers = (serversQuery.data ?? []).toSorted((a, b) => a.display_name.localeCompare(b.display_name), ); @@ -29,20 +56,39 @@ const MCPServersPage: FC = () => { } > Codestin Search App - { - setSearchParams((params) => { - const next = new URLSearchParams(params); - next.set(orgSearchParam, org.name); - return next; - }); - }} - /> + {organizationPermissionsQuery.isError ? ( + + ) : !permissions.editDeploymentConfig && + !organizationPermissionsQuery.data ? ( + + ) : ( + + {organization && ( + { + setSearchParams((params) => { + const next = new URLSearchParams(params); + next.set(orgSearchParam, org.name); + return next; + }); + }} + /> + )} + + )} ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx index a36cb33c088..6a4c139926c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx @@ -27,6 +27,8 @@ const meta: Meta = { ], organizations: [MockDefaultOrganization], organization: MockDefaultOrganization, + canCreate: true, + canUpdate: true, onSelectOrganization: fn(), }, parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index d40e3cf40af..9e8cd8a7c34 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -28,6 +28,8 @@ interface MCPServersPageViewProps { servers: readonly TypesGen.MCPServerConfig[]; organizations: readonly TypesGen.Organization[]; organization: TypesGen.Organization; + canCreate: boolean; + canUpdate: boolean; onSelectOrganization: (organization: TypesGen.Organization) => void; } @@ -37,6 +39,8 @@ const MCPServersPageView: FC = ({ servers, organizations, organization, + canCreate, + canUpdate, onSelectOrganization, }) => { const navigate = useNavigate(); @@ -46,10 +50,12 @@ const MCPServersPageView: FC = ({
- - Add server - + canCreate && ( + + ) } > MCP servers @@ -90,10 +96,12 @@ const MCPServersPageView: FC = ({ message="No MCP servers configured" description="Add a server to give agents access to external tools." cta={ - + canCreate ? ( + + ) : undefined } /> ) : ( @@ -101,8 +109,13 @@ const MCPServersPageView: FC = ({ - void navigate(updateMCPServerPath(server.id, organization)) + onClick={ + canUpdate + ? () => + void navigate( + updateMCPServerPath(server.id, organization), + ) + : undefined } /> )) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 3081f794497..02848a2c86f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -13,6 +13,7 @@ import { mcpServerConfig, updateMCPServerConfig, } from "#/api/queries/chats"; +import { organizationsPermissions } from "#/api/queries/organizations"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -31,22 +32,52 @@ const UpdateMCPServerPage: FC = () => { const { organizations } = useDashboard(); const { serverId } = useParams<{ serverId: string }>(); const [searchParams] = useSearchParams(); - const organization = selectOrganization( - organizations, - searchParams.get(orgSearchParam), + const organizationPermissionsQuery = useQuery( + organizationsPermissions( + organizations.map((organization) => organization.id), + ), ); + const authorizedOrganizations = permissions.editDeploymentConfig + ? organizations + : organizations.filter((organization) => { + const organizationPermissions = + organizationPermissionsQuery.data?.[organization.id]; + return ( + organizationPermissions?.viewMCPServerConfigs && + organizationPermissions.updateMCPServerConfig + ); + }); + const organization = + authorizedOrganizations.length > 0 + ? selectOrganization( + authorizedOrganizations, + searchParams.get(orgSearchParam), + ) + : undefined; + const organizationPermissions = organization + ? organizationPermissionsQuery.data?.[organization.id] + : undefined; + const canUpdate = + permissions.editDeploymentConfig || + Boolean( + organizationPermissions?.viewMCPServerConfigs && + organizationPermissions.updateMCPServerConfig, + ); + const canDelete = + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.deleteMCPServerConfig); const queryClient = useQueryClient(); const navigate = useNavigate(); const serverQuery = useQuery({ - ...mcpServerConfig(organization.id, serverId ?? ""), - enabled: Boolean(serverId), + ...mcpServerConfig(organization?.id ?? "", serverId ?? ""), + enabled: Boolean(serverId) && canUpdate, }); const server = serverQuery.data; const updateMutation = useMutation( - updateMCPServerConfig(queryClient, organization.id), + updateMCPServerConfig(queryClient, organization?.id ?? ""), ); const deleteMutation = useMutation( - deleteMCPServerConfig(queryClient, organization.id), + deleteMCPServerConfig(queryClient, organization?.id ?? ""), ); // A 404 must win over cached data: a refetch failure keeps stale data, // which would otherwise render a form for a deleted or concealed server. @@ -54,7 +85,9 @@ const UpdateMCPServerPage: FC = () => { serverQuery.isError && isApiError(serverQuery.error) && serverQuery.error.response.status === 404; - const listPath = mcpServersPath(organization); + const listPath = organization + ? mcpServersPath(organization) + : "/ai/settings/mcp-servers"; return ( { permissions.editDeploymentConfig || permissions.updateAnyMCPServerConfig } > - {!serverId ? ( - - ) : serverQuery.isLoading ? ( - <> - Codestin Search App - - - ) : serverQuery.isLoadingError && !notFound ? ( - <> - Codestin Search App -
- -
- - ) : notFound || !server ? ( - + {organizationPermissionsQuery.isError ? ( + + ) : !permissions.editDeploymentConfig && + !organizationPermissionsQuery.data ? ( + ) : ( - <> - {serverQuery.isRefetchError && ( -
- -
- )} - void navigate(listPath)} - onUpdateServer={async (id, req) => { - try { - const updated = await updateMutation.mutateAsync({ id, req }); - toast.success(`MCP server "${updated.display_name}" updated.`); - await navigate(listPath); - } catch (error) { - toast.error( - getErrorMessage(error, "Failed to update MCP server."), - ); - } - }} - onDeleteServer={async (id) => { - try { - await deleteMutation.mutateAsync(id); - toast.success(`MCP server "${server.display_name}" deleted.`); - await navigate(listPath, { replace: true }); - } catch (error) { - toast.error( - getErrorMessage(error, "Failed to delete MCP server."), - ); - } - }} - onToggleEnabled={(enabled) => { - updateMutation.mutate( - { id: server.id, req: { enabled } }, - { - onSuccess: () => { + + {!serverId ? ( + + ) : serverQuery.isLoading ? ( + <> + Codestin Search App + + + ) : serverQuery.isLoadingError && !notFound ? ( + <> + Codestin Search App +
+ +
+ + ) : notFound || !server ? ( + + ) : ( + <> + {serverQuery.isRefetchError && ( +
+ +
+ )} + void navigate(listPath)} + onUpdateServer={async (id, req) => { + try { + const updated = await updateMutation.mutateAsync({ + id, + req, + }); toast.success( - `MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`, + `MCP server "${updated.display_name}" updated.`, ); - }, - onError: (error) => { + await navigate(listPath); + } catch (error) { toast.error( - getErrorMessage( - error, - `Failed to ${enabled ? "enable" : "disable"} MCP server.`, - ), + getErrorMessage(error, "Failed to update MCP server."), ); - }, - }, - ); - }} - /> - + } + }} + onDeleteServer={ + canDelete + ? async (id) => { + try { + await deleteMutation.mutateAsync(id); + toast.success( + `MCP server "${server.display_name}" deleted.`, + ); + await navigate(listPath, { replace: true }); + } catch (error) { + toast.error( + getErrorMessage( + error, + "Failed to delete MCP server.", + ), + ); + } + } + : undefined + } + onToggleEnabled={(enabled) => { + updateMutation.mutate( + { id: server.id, req: { enabled } }, + { + onSuccess: () => { + toast.success( + `MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to ${enabled ? "enable" : "disable"} MCP server.`, + ), + ); + }, + }, + ); + }} + /> + + )} +
)}
); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index 3be6b8a9075..755fed998e0 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -12,7 +12,7 @@ interface UpdateMCPServerPageViewProps { serverId: string, req: TypesGen.UpdateMCPServerConfigRequest, ) => Promise; - onDeleteServer: (serverId: string) => Promise; + onDeleteServer?: (serverId: string) => Promise; onToggleEnabled: (enabled: boolean) => void; onCancel: () => void; } diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx index 34a7e40e68b..29b4e357765 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx @@ -10,15 +10,17 @@ import { AUTH_TYPE_LABELS, AVAILABILITY_LABELS } from "./mcpServerFormLogic"; interface MCPServerRowProps { server: TypesGen.MCPServerConfig; - onClick: () => void; + onClick?: () => void; } export const MCPServerRow: FC = ({ server, onClick }) => { - const clickableProps = useClickableTableRow({ onClick }); + const clickableProps = useClickableTableRow({ + onClick: () => onClick?.(), + }); const enabled = server.enabled; return ( - +
= ({ server, onClick }) => { {enabled ? "Enabled" : "Disabled"} - + {onClick && ( + + )} ); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7beeb07760c..14dee625859 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3436,6 +3436,10 @@ export const MockOrganizationPermissions: OrganizationPermissions = { viewProvisionerJobs: true, viewIdpSyncSettings: true, editIdpSyncSettings: true, + viewMCPServerConfigs: true, + createMCPServerConfig: true, + updateMCPServerConfig: true, + deleteMCPServerConfig: true, }; export const MockNoOrganizationPermissions: OrganizationPermissions = { @@ -3454,6 +3458,10 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { viewProvisionerJobs: false, viewIdpSyncSettings: false, editIdpSyncSettings: false, + viewMCPServerConfigs: false, + createMCPServerConfig: false, + updateMCPServerConfig: false, + deleteMCPServerConfig: false, }; export const MockDeploymentConfig: DeploymentConfig = { From 58093f464b1bd114699864915b73653dd6dc6830 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:32:12 +0000 Subject: [PATCH 24/51] fix(site): filter MCP organizations by view permission --- .../dashboard/Navbar/NavbarView.stories.tsx | 20 +- .../AISettingsSidebarView.stories.tsx | 31 +++ .../AddMCPServerPage/AddMCPServerPage.tsx | 22 ++- .../MCPServersPage/MCPServersPage.stories.tsx | 177 ++++++++++++++++++ .../UpdateMCPServerPage.tsx | 20 +- 5 files changed, 252 insertions(+), 18 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index dd88e6cac51..9c4ed30f98c 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import type { TasksFilter } from "#/api/typesGenerated"; import { MockBuildInfo, @@ -91,6 +91,24 @@ export const ForOrgAdmin: Story = { }, }; +export const ForMCPOrgAdmin: Story = { + parameters: { pixel: { matrix: pixelWithDesktop } }, + args: { + user: MockUserMember, + adminPermissions: { + canViewAISettings: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Admin settings" }), + ); + const body = within(canvasElement.ownerDocument.body); + await expect(body.getByRole("menuitem", { name: "AI" })).toBeVisible(); + }, +}; + export const ForSingleOrgOSSAdmin: Story = { parameters: { pixel: { matrix: pixelWithDesktop } }, args: { diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index 70a976d2110..ae31d92e800 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -97,6 +97,37 @@ export const NoUpdateTemplates: Story = { }, }; +export const MCPServersForOrgAdmin: Story = { + args: { + permissions: { + ...MockNoPermissions, + viewAnyMCPServerConfigs: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: "MCP servers" }), + ).toBeVisible(); + }, +}; + +export const MCPServersHiddenWithoutPermission: Story = { + args: { + permissions: { + ...MockNoPermissions, + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.queryByRole("link", { name: "MCP servers" }), + ).not.toBeInTheDocument(); + }, +}; + export const NoPermissions: Story = { args: { permissions: MockNoPermissions, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 419f54c88f4..6452a9f430b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -27,16 +27,20 @@ const AddMCPServerPage: FC = () => { organizations.map((organization) => organization.id), ), ); + const viewableOrganizations = permissions.editDeploymentConfig + ? organizations + : organizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .viewMCPServerConfigs, + ); const authorizedOrganizations = permissions.editDeploymentConfig ? organizations - : organizations.filter((organization) => { - const organizationPermissions = - organizationPermissionsQuery.data?.[organization.id]; - return ( - organizationPermissions?.viewMCPServerConfigs && - organizationPermissions.createMCPServerConfig - ); - }); + : viewableOrganizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .createMCPServerConfig, + ); const organization = authorizedOrganizations.length > 0 ? selectOrganization( @@ -77,7 +81,7 @@ const AddMCPServerPage: FC = () => { {organization && ( { setSearchParams((params) => { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index ab97b4164d8..be17fb23623 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -36,6 +36,41 @@ const mockOrganization2MCPServerQueryKey = mcpServerConfigKey( MockOrganization2MCPServer.id, ); +type MCPOrganizationStoryPermissions = Readonly<{ + view?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +}>; + +const mockOrganizationPermissions = ( + permissionsByOrganizationId: Readonly< + Record + >, +) => { + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.keys(checks).map((key) => { + const separator = key.indexOf("."); + const organizationId = key.slice(0, separator); + const permission = key.slice(separator + 1); + const permissions = permissionsByOrganizationId[organizationId]; + const allowed = + permission === "viewMCPServerConfigs" + ? permissions?.view + : permission === "createMCPServerConfig" + ? permissions?.create + : permission === "updateMCPServerConfig" + ? permissions?.update + : permission === "deleteMCPServerConfig" + ? permissions?.delete + : false; + return [key, Boolean(allowed)]; + }), + ), + ); +}; + const RefetchServerDetailProbe: FC = () => { const queryClient = useQueryClient(); return ( @@ -125,6 +160,38 @@ export const OrgAdminCanViewMCPServers: Story = { }), }, beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByRole("cell", { name: "Coder" }), + ).toBeVisible(); + }, +}; + +export const ReadOnlyOrgAdminCannotModifyMCPServers: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: false, + updateAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true }, + }); spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ MockCoderMCPServer, ]); @@ -134,6 +201,57 @@ export const OrgAdminCanViewMCPServers: Story = { await expect( await canvas.findByRole("cell", { name: "Coder" }), ).toBeVisible(); + expect( + canvas.queryByRole("button", { name: "Add server" }), + ).not.toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /Coder/ }), + ).not.toBeInTheDocument(); + }, +}; + +export const OrgBOnlyAdminUsesAuthorizedOrganization: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: true, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/mcp-servers", + searchParams: { [orgSearchParam]: MockDefaultOrganization.name }, + }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockOrganization2.id]: { view: true, create: true, update: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + async (organization) => + organization === MockOrganization2.id + ? [MockOrganization2MCPServer] + : [MockCoderMCPServer], + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByRole("cell", { name: "Org2 Search" }), + ).toBeVisible(); + expect(canvas.queryByText("Coder")).not.toBeInTheDocument(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).not.toBeInTheDocument(); + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockOrganization2.id, + ); }, }; @@ -210,6 +328,33 @@ export const ListDisambiguatesCollidingOrganizationNames: Story = { }, }; +export const OrgAdminCanAddMCPServer: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, create: true }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); + await expect( + canvas.getByRole("button", { name: "Add server" }), + ).toBeVisible(); + }, +}; + export const AddUsesDefaultOrganization: Story = { render: () => , parameters: { @@ -395,6 +540,38 @@ export const UpdateShowsDetailLoadError: Story = { }, }; +export const OrgAdminCanUpdateMCPServer: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + updateAnyMCPServerConfig: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, update: true }, + }); + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( + "Coder", + ); + expect( + canvas.queryByRole("button", { name: /delete server/i }), + ).not.toBeInTheDocument(); + }, +}; + export const UpdateLoadsServerById: Story = { render: () => , parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 02848a2c86f..f0e32f8baad 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -37,16 +37,20 @@ const UpdateMCPServerPage: FC = () => { organizations.map((organization) => organization.id), ), ); + const viewableOrganizations = permissions.editDeploymentConfig + ? organizations + : organizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .viewMCPServerConfigs, + ); const authorizedOrganizations = permissions.editDeploymentConfig ? organizations - : organizations.filter((organization) => { - const organizationPermissions = - organizationPermissionsQuery.data?.[organization.id]; - return ( - organizationPermissions?.viewMCPServerConfigs && - organizationPermissions.updateMCPServerConfig - ); - }); + : viewableOrganizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .updateMCPServerConfig, + ); const organization = authorizedOrganizations.length > 0 ? selectOrganization( From 484c287db47573499c2bfaad2793e8d36bdd5c03 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:33:24 +0000 Subject: [PATCH 25/51] fix(site): gate selected MCP organization actions --- .../AddMCPServerPage/AddMCPServerPage.tsx | 11 ++--------- .../UpdateMCPServerPage/UpdateMCPServerPage.tsx | 11 ++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 6452a9f430b..abc37a25496 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -34,17 +34,10 @@ const AddMCPServerPage: FC = () => { organizationPermissionsQuery.data?.[organization.id] .viewMCPServerConfigs, ); - const authorizedOrganizations = permissions.editDeploymentConfig - ? organizations - : viewableOrganizations.filter( - (organization) => - organizationPermissionsQuery.data?.[organization.id] - .createMCPServerConfig, - ); const organization = - authorizedOrganizations.length > 0 + viewableOrganizations.length > 0 ? selectOrganization( - authorizedOrganizations, + viewableOrganizations, searchParams.get(orgSearchParam), ) : undefined; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index f0e32f8baad..57711544ccd 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -44,17 +44,10 @@ const UpdateMCPServerPage: FC = () => { organizationPermissionsQuery.data?.[organization.id] .viewMCPServerConfigs, ); - const authorizedOrganizations = permissions.editDeploymentConfig - ? organizations - : viewableOrganizations.filter( - (organization) => - organizationPermissionsQuery.data?.[organization.id] - .updateMCPServerConfig, - ); const organization = - authorizedOrganizations.length > 0 + viewableOrganizations.length > 0 ? selectOrganization( - authorizedOrganizations, + viewableOrganizations, searchParams.get(orgSearchParam), ) : undefined; From 49fa7f664559881b8ec50be47df5b51d2b14ca6a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:39:10 +0000 Subject: [PATCH 26/51] fix(site): stabilize MCP permission gating stories --- .../modules/dashboard/Navbar/NavbarView.stories.tsx | 13 +++++++++++-- .../AddMCPServerPage/AddMCPServerPage.tsx | 7 ++++--- .../MCPServersPage/MCPServersPage.stories.tsx | 6 +++--- .../MCPServersPage/MCPServersPage.tsx | 7 ++++--- .../UpdateMCPServerPage/UpdateMCPServerPage.tsx | 7 ++++--- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 9c4ed30f98c..8f6ee1cbc37 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -15,6 +15,10 @@ const tasksFilter: TasksFilter = { owner: MockUserOwner.username, }; +const memberTasksFilter: TasksFilter = { + owner: MockUserMember.username, +}; + const meta: Meta = { title: "modules/dashboard/NavbarView", parameters: { @@ -92,7 +96,10 @@ export const ForOrgAdmin: Story = { }; export const ForMCPOrgAdmin: Story = { - parameters: { pixel: { matrix: pixelWithDesktop } }, + parameters: { + pixel: { matrix: pixelWithDesktop }, + queries: [{ key: ["tasks", memberTasksFilter], data: [] }], + }, args: { user: MockUserMember, adminPermissions: { @@ -105,7 +112,9 @@ export const ForMCPOrgAdmin: Story = { canvas.getByRole("button", { name: "Admin settings" }), ); const body = within(canvasElement.ownerDocument.body); - await expect(body.getByRole("menuitem", { name: "AI" })).toBeVisible(); + await expect( + body.getByRole("menuitem", { name: "AI" }), + ).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index abc37a25496..d37df17957e 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -22,11 +22,12 @@ const AddMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const [searchParams, setSearchParams] = useSearchParams(); - const organizationPermissionsQuery = useQuery( - organizationsPermissions( + const organizationPermissionsQuery = useQuery({ + ...organizationsPermissions( organizations.map((organization) => organization.id), ), - ); + enabled: !permissions.editDeploymentConfig, + }); const viewableOrganizations = permissions.editDeploymentConfig ? organizations : organizations.filter( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index be17fb23623..20304fa75a0 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -170,7 +170,7 @@ export const OrgAdminCanViewMCPServers: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - await canvas.findByRole("cell", { name: "Coder" }), + await canvas.findByRole("cell", { name: /Coder/ }), ).toBeVisible(); }, }; @@ -199,7 +199,7 @@ export const ReadOnlyOrgAdminCannotModifyMCPServers: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - await canvas.findByRole("cell", { name: "Coder" }), + await canvas.findByRole("cell", { name: /Coder/ }), ).toBeVisible(); expect( canvas.queryByRole("button", { name: "Add server" }), @@ -241,7 +241,7 @@ export const OrgBOnlyAdminUsesAuthorizedOrganization: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - await canvas.findByRole("cell", { name: "Org2 Search" }), + await canvas.findByRole("cell", { name: /Org2 Search/ }), ).toBeVisible(); expect(canvas.queryByText("Coder")).not.toBeInTheDocument(); expect( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index a831da48017..2e4b95636aa 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -16,11 +16,12 @@ const MCPServersPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const [searchParams, setSearchParams] = useSearchParams(); - const organizationPermissionsQuery = useQuery( - organizationsPermissions( + const organizationPermissionsQuery = useQuery({ + ...organizationsPermissions( organizations.map((organization) => organization.id), ), - ); + enabled: !permissions.editDeploymentConfig, + }); const authorizedOrganizations = permissions.editDeploymentConfig ? organizations : organizations.filter( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 57711544ccd..13483a2f846 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -32,11 +32,12 @@ const UpdateMCPServerPage: FC = () => { const { organizations } = useDashboard(); const { serverId } = useParams<{ serverId: string }>(); const [searchParams] = useSearchParams(); - const organizationPermissionsQuery = useQuery( - organizationsPermissions( + const organizationPermissionsQuery = useQuery({ + ...organizationsPermissions( organizations.map((organization) => organization.id), ), - ); + enabled: !permissions.editDeploymentConfig, + }); const viewableOrganizations = permissions.editDeploymentConfig ? organizations : organizations.filter( From 315ac64fb8923728972422d2084111ed7ba05c71 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:52:13 +0000 Subject: [PATCH 27/51] fix(site): keep MCP org picker and delete action reachable under partial permissions --- .../AddMCPServerPage/AddMCPServerPage.tsx | 83 ++++---- .../AddMCPServerPageView.stories.tsx | 1 + .../AddMCPServerPage/AddMCPServerPageView.tsx | 17 +- .../MCPServersPage/MCPServersPage.stories.tsx | 181 ++++++++++++++++++ .../MCPServersPage/MCPServersPage.tsx | 4 + .../MCPServersPageView.stories.tsx | 1 + .../MCPServersPage/MCPServersPageView.tsx | 4 +- .../UpdateMCPServerPage.tsx | 79 ++++---- .../UpdateMCPServerPageView.tsx | 4 +- .../components/MCPServerForm.tsx | 8 +- .../components/MCPServerFormHeader.tsx | 2 +- .../components/OrganizationPicker.tsx | 5 +- 12 files changed, 301 insertions(+), 88 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index d37df17957e..2e81cf43daa 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -35,12 +35,24 @@ const AddMCPServerPage: FC = () => { organizationPermissionsQuery.data?.[organization.id] .viewMCPServerConfigs, ); - const organization = - viewableOrganizations.length > 0 - ? selectOrganization( - viewableOrganizations, - searchParams.get(orgSearchParam), - ) + const creatableOrganizations = permissions.editDeploymentConfig + ? organizations + : viewableOrganizations.filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + .createMCPServerConfig, + ); + const requestedOrganization = viewableOrganizations.find( + (organization) => organization.name === searchParams.get(orgSearchParam), + ); + const selectableOrganizations = + creatableOrganizations.length > 0 + ? creatableOrganizations + : viewableOrganizations; + const organization = requestedOrganization + ? requestedOrganization + : selectableOrganizations.length > 0 + ? selectOrganization(selectableOrganizations, null) : undefined; const organizationPermissions = organization ? organizationPermissionsQuery.data?.[organization.id] @@ -68,37 +80,36 @@ const AddMCPServerPage: FC = () => { ) : !permissions.editDeploymentConfig && !organizationPermissionsQuery.data ? ( + ) : creatableOrganizations.length === 0 ? ( + ) : ( - - {organization && ( - { - setSearchParams((params) => { - const next = new URLSearchParams(params); - next.set(orgSearchParam, org.name); - return next; - }); - }} - onCancel={() => void navigate(mcpServersPath(organization))} - onCreateServer={async (req) => { - try { - const server = await createMutation.mutateAsync(req); - toast.success(`MCP server "${server.display_name}" added.`); - await navigate(updateMCPServerPath(server.id, organization)); - } catch (error) { - toast.error( - getErrorMessage(error, "Failed to add MCP server."), - ); - } - }} - /> - )} - + organization && ( + { + setSearchParams((params) => { + const next = new URLSearchParams(params); + next.set(orgSearchParam, org.name); + return next; + }); + }} + onCancel={() => void navigate(mcpServersPath(organization))} + onCreateServer={async (req) => { + try { + const server = await createMutation.mutateAsync(req); + toast.success(`MCP server "${server.display_name}" added.`); + await navigate(updateMCPServerPath(server.id, organization)); + } catch (error) { + toast.error( + getErrorMessage(error, "Failed to add MCP server."), + ); + } + }} + /> + ) )} ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 8d1201c418f..679a9263a14 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { component: AddMCPServerPageView, args: { isSaving: false, + canCreate: true, organizations: [MockDefaultOrganization], organization: MockDefaultOrganization, onSelectOrganization: fn(), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 23a5f5c9f16..bd55c3c616e 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; import { MCPServerForm } from "../components/MCPServerForm"; import { OrganizationPicker } from "../components/OrganizationPicker"; @@ -7,6 +8,7 @@ import { mcpServersPath } from "../organizationParam"; interface AddMCPServerPageViewProps { isSaving: boolean; + canCreate: boolean; organizations: readonly TypesGen.Organization[]; organization: TypesGen.Organization; onSelectOrganization: (organization: TypesGen.Organization) => void; @@ -18,6 +20,7 @@ interface AddMCPServerPageViewProps { const AddMCPServerPageView: FC = ({ isSaving, + canCreate, organizations, organization, onSelectOrganization, @@ -35,12 +38,14 @@ const AddMCPServerPageView: FC = ({ onChange={onSelectOrganization} disabled={isSaving} /> - + + + ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 20304fa75a0..5a0195746ab 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -23,6 +23,13 @@ import { orgSearchParam } from "./organizationParam"; import { MockCoderMCPServer } from "./testFixtures"; import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage"; +const MockOrganization3: TypesGen.Organization = { + ...MockOrganization2, + id: "organization-3", + name: "organization-3", + display_name: "Organization 3", +}; + const MockOrganization2MCPServer: TypesGen.MCPServerConfig = { ...MockCoderMCPServer, id: "mcp-org2", @@ -210,6 +217,41 @@ export const ReadOnlyOrgAdminCannotModifyMCPServers: Story = { }, }; +export const DeleteOnlyOrgAdminCanOpenMCPServer: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + updateAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: [ + { path: "/ai/settings/mcp-servers", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/:serverId", + element: , + }, + ], + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, delete: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText("Coder")); + await expect( + await canvas.findByText(`detail-org:${MockDefaultOrganization.name}`), + ).toBeVisible(); + }, +}; + export const OrgBOnlyAdminUsesAuthorizedOrganization: Story = { parameters: { permissions: { @@ -355,6 +397,94 @@ export const OrgAdminCanAddMCPServer: Story = { }, }; +export const AddPickerExcludesNonCreatableOrganizations: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + }, + organizations: [ + MockDefaultOrganization, + MockOrganization2, + MockOrganization3, + ], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, create: true }, + [MockOrganization2.id]: { view: true }, + [MockOrganization3.id]: { view: true, create: true }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ); + const body = within(canvasElement.ownerDocument.body); + await expect( + await body.findByRole("option", { name: MockOrganization3.display_name }), + ).toBeVisible(); + expect( + body.queryByRole("option", { name: MockOrganization2.display_name }), + ).not.toBeInTheDocument(); + }, +}; + +export const AddDeepLinkedNonCreatableOrganizationCanSwitch: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/mcp-servers/add", + searchParams: { [orgSearchParam]: MockOrganization2.name }, + }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, create: true }, + [MockOrganization2.id]: { view: true }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await expect( + await body.findByText("You don't have permission to view this page"), + ).toBeVisible(); + const picker = canvas.getByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }); + await expect(picker).toBeVisible(); + await userEvent.click(picker); + await userEvent.click( + await body.findByRole("option", { + name: MockDefaultOrganization.display_name, + }), + ); + await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); + expect( + body.queryByText("You don't have permission to view this page"), + ).not.toBeInTheDocument(); + }, +}; + export const AddUsesDefaultOrganization: Story = { render: () => , parameters: { @@ -566,12 +696,63 @@ export const OrgAdminCanUpdateMCPServer: Story = { await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( "Coder", ); + await expect( + canvas.getByRole("button", { name: "Update server" }), + ).toBeEnabled(); + await expect( + canvas.getByRole("switch", { name: "Server enabled" }), + ).toBeEnabled(); expect( canvas.queryByRole("button", { name: /delete server/i }), ).not.toBeInTheDocument(); }, }; +export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + updateAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, delete: true }, + }); + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( + "Coder", + ); + await expect( + canvas.getByRole("button", { name: "Update server" }), + ).toBeDisabled(); + await expect( + canvas.getByRole("switch", { name: "Server enabled" }), + ).toBeDisabled(); + await userEvent.click( + canvas.getByRole("button", { name: "Server actions" }), + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "Remove" }), + ); + await expect( + await body.findByRole("button", { name: "Delete MCP server" }), + ).toBeVisible(); + }, +}; + export const UpdateLoadsServerById: Story = { render: () => , parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 2e4b95636aa..6ec4144e6fd 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -79,6 +79,10 @@ const MCPServersPage: FC = () => { permissions.editDeploymentConfig || Boolean(organizationPermissions?.updateMCPServerConfig) } + canDelete={ + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.deleteMCPServerConfig) + } onSelectOrganization={(org) => { setSearchParams((params) => { const next = new URLSearchParams(params); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx index 6a4c139926c..a2d28956fba 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.stories.tsx @@ -28,6 +28,7 @@ const meta: Meta = { organizations: [MockDefaultOrganization], organization: MockDefaultOrganization, canCreate: true, + canDelete: true, canUpdate: true, onSelectOrganization: fn(), }, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index 9e8cd8a7c34..51a784e3728 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -30,6 +30,7 @@ interface MCPServersPageViewProps { organization: TypesGen.Organization; canCreate: boolean; canUpdate: boolean; + canDelete: boolean; onSelectOrganization: (organization: TypesGen.Organization) => void; } @@ -41,6 +42,7 @@ const MCPServersPageView: FC = ({ organization, canCreate, canUpdate, + canDelete, onSelectOrganization, }) => { const navigate = useNavigate(); @@ -110,7 +112,7 @@ const MCPServersPageView: FC = ({ key={server.id} server={server} onClick={ - canUpdate + canUpdate || canDelete ? () => void navigate( updateMCPServerPath(server.id, organization), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 13483a2f846..442d7626aa6 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -14,6 +14,7 @@ import { updateMCPServerConfig, } from "#/api/queries/chats"; import { organizationsPermissions } from "#/api/queries/organizations"; +import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -68,7 +69,7 @@ const UpdateMCPServerPage: FC = () => { const navigate = useNavigate(); const serverQuery = useQuery({ ...mcpServerConfig(organization?.id ?? "", serverId ?? ""), - enabled: Boolean(serverId) && canUpdate, + enabled: Boolean(serverId) && (canUpdate || canDelete), }); const server = serverQuery.data; const updateMutation = useMutation( @@ -86,11 +87,24 @@ const UpdateMCPServerPage: FC = () => { const listPath = organization ? mcpServersPath(organization) : "/ai/settings/mcp-servers"; + const onUpdateServer = canUpdate + ? async (id: string, req: TypesGen.UpdateMCPServerConfigRequest) => { + try { + const updated = await updateMutation.mutateAsync({ id, req }); + toast.success(`MCP server "${updated.display_name}" updated.`); + await navigate(listPath); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to update MCP server.")); + } + } + : undefined; return ( {organizationPermissionsQuery.isError ? ( @@ -100,7 +114,7 @@ const UpdateMCPServerPage: FC = () => { ) : ( {!serverId ? ( @@ -131,22 +145,7 @@ const UpdateMCPServerPage: FC = () => { isSaving={updateMutation.isPending} isDeleting={deleteMutation.isPending} onCancel={() => void navigate(listPath)} - onUpdateServer={async (id, req) => { - try { - const updated = await updateMutation.mutateAsync({ - id, - req, - }); - toast.success( - `MCP server "${updated.display_name}" updated.`, - ); - await navigate(listPath); - } catch (error) { - toast.error( - getErrorMessage(error, "Failed to update MCP server."), - ); - } - }} + onUpdateServer={onUpdateServer} onDeleteServer={ canDelete ? async (id) => { @@ -167,26 +166,30 @@ const UpdateMCPServerPage: FC = () => { } : undefined } - onToggleEnabled={(enabled) => { - updateMutation.mutate( - { id: server.id, req: { enabled } }, - { - onSuccess: () => { - toast.success( - `MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`, - ); - }, - onError: (error) => { - toast.error( - getErrorMessage( - error, - `Failed to ${enabled ? "enable" : "disable"} MCP server.`, - ), + onToggleEnabled={ + canUpdate + ? (enabled) => { + updateMutation.mutate( + { id: server.id, req: { enabled } }, + { + onSuccess: () => { + toast.success( + `MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to ${enabled ? "enable" : "disable"} MCP server.`, + ), + ); + }, + }, ); - }, - }, - ); - }} + } + : undefined + } /> )} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index 755fed998e0..08140e3eded 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -8,12 +8,12 @@ interface UpdateMCPServerPageViewProps { listPath: string; isSaving: boolean; isDeleting: boolean; - onUpdateServer: ( + onUpdateServer?: ( serverId: string, req: TypesGen.UpdateMCPServerConfigRequest, ) => Promise; onDeleteServer?: (serverId: string) => Promise; - onToggleEnabled: (enabled: boolean) => void; + onToggleEnabled?: (enabled: boolean) => void; onCancel: () => void; } diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index 4ad7aa0de23..7d91f9f1f15 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -33,7 +33,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; onCreateServer?: undefined; - onUpdateServer: ( + onUpdateServer?: ( serverId: string, req: TypesGen.UpdateMCPServerConfigRequest, ) => Promise; @@ -78,7 +78,9 @@ export const MCPServerForm: FC = ({ }); const isDisabled = isSaving || isDeleting; - const canSubmit = canSubmitMCPServerForm(form.values, isDisabled); + const canUpdate = !isEditing || Boolean(onUpdateServer); + const canSubmit = + canUpdate && canSubmitMCPServerForm(form.values, isDisabled); const unsavedChanges = useUnsavedChangesPrompt( form.dirty && !form.isSubmitting, ); @@ -102,7 +104,7 @@ export const MCPServerForm: FC = ({ = ({ onToggleEnabled?.(checked)} - disabled={isDisabled} + disabled={isDisabled || !onToggleEnabled} aria-label="Server enabled" /> diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index c9ed5775f4d..5226104c04e 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -24,7 +24,10 @@ export const OrganizationPicker: FC = ({ className, disabled, }) => { - if (organizations.length <= 1) { + if ( + organizations.length <= 1 && + organizations.some((option) => option.id === organization.id) + ) { return null; } From 309b14f35c362889e2490a91d071f7161cd4b998 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:57:29 +0000 Subject: [PATCH 28/51] fix(site): keep partial MCP actions accessible --- .../AddMCPServerPage/AddMCPServerPageView.tsx | 13 ++++++++++--- .../MCPServersPage/MCPServersPage.stories.tsx | 17 +++++++++-------- .../MCPServersPage/components/MCPServerForm.tsx | 6 ++++-- .../components/MCPServerFormHeader.tsx | 4 ++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index bd55c3c616e..d2de2b52a1a 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; -import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { pageTitle } from "#/utils/page"; import { MCPServerForm } from "../components/MCPServerForm"; import { OrganizationPicker } from "../components/OrganizationPicker"; @@ -38,14 +38,21 @@ const AddMCPServerPageView: FC = ({ onChange={onSelectOrganization} disabled={isSaving} /> - + {canCreate ? ( - + ) : ( + + You cannot add servers to this organization + + Choose an organization where you have permission to add MCP servers. + + + )} ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 5a0195746ab..cfac7b816d1 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -432,7 +432,7 @@ export const AddPickerExcludesNonCreatableOrganizations: Story = { const body = within(canvasElement.ownerDocument.body); await expect( await body.findByRole("option", { name: MockOrganization3.display_name }), - ).toBeVisible(); + ).toBeInTheDocument(); expect( body.queryByRole("option", { name: MockOrganization2.display_name }), ).not.toBeInTheDocument(); @@ -465,9 +465,9 @@ export const AddDeepLinkedNonCreatableOrganizationCanSwitch: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); - await expect( - await body.findByText("You don't have permission to view this page"), - ).toBeVisible(); + await expect(await canvas.findByRole("alert")).toHaveTextContent( + "You cannot add servers to this organization", + ); const picker = canvas.getByRole("button", { name: `Organization ${MockOrganization2.display_name}`, }); @@ -479,9 +479,7 @@ export const AddDeepLinkedNonCreatableOrganizationCanSwitch: Story = { }), ); await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); - expect( - body.queryByText("You don't have permission to view this page"), - ).not.toBeInTheDocument(); + expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); }, }; @@ -702,6 +700,9 @@ export const OrgAdminCanUpdateMCPServer: Story = { await expect( canvas.getByRole("switch", { name: "Server enabled" }), ).toBeEnabled(); + expect( + canvas.queryByRole("button", { name: "Server actions" }), + ).not.toBeInTheDocument(); expect( canvas.queryByRole("button", { name: /delete server/i }), ).not.toBeInTheDocument(); @@ -749,7 +750,7 @@ export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = { ); await expect( await body.findByRole("button", { name: "Delete MCP server" }), - ).toBeVisible(); + ).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index 7d91f9f1f15..5476f906512 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -97,14 +97,16 @@ export const MCPServerForm: FC = ({ listPath={listPath} isEditing={isEditing} isDisabled={isDisabled} - onRequestDelete={() => setConfirmingDelete(true)} + onRequestDelete={ + onDeleteServer ? () => setConfirmingDelete(true) : undefined + } onToggleEnabled={onToggleEnabled} />
void; + onRequestDelete?: () => void; onToggleEnabled?: (enabled: boolean) => void; } @@ -56,7 +56,7 @@ export const MCPServerFormHeader: FC = ({ <>
- {isEditing && server && ( + {isEditing && server && onRequestDelete && ( + ); +}; + +const withRefetchPermissionsProbe: Decorator = (Story) => ( + <> + + + +); + const ListRedirectProbe: FC = () => { const [searchParams] = useSearchParams(); return
list-org:{searchParams.get(orgSearchParam) ?? "none"}
; @@ -393,6 +419,48 @@ export const OrgAdminCanAddMCPServer: Story = { }, }; +export const AddPermissionsRefetchErrorKeepsForm: Story = { + render: () => , + decorators: [withRefetchPermissionsProbe], + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + const checkAuthorization = spyOn(API, "checkAuthorization"); + checkAuthorization.mockImplementationOnce(async ({ checks }) => + Object.fromEntries(Object.keys(checks).map((key) => [key, true])), + ); + checkAuthorization.mockRejectedValue( + mockApiError({ message: "failed to refresh permissions" }), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const displayName = await canvas.findByLabelText(/display name/i); + await userEvent.type(displayName, "Draft server"); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch permissions" }), + ); + const alert = await canvas.findByRole("alert"); + await expect( + within(alert).getByRole("heading", { + name: /failed to refresh permissions/i, + }), + ).toBeVisible(); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue( + "Draft server", + ); + }, +}; + export const AddPickerExcludesNonCreatableOrganizations: Story = { render: () => , parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 53906ad5f24..94f84dc8698 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -66,13 +66,18 @@ const MCPServersPage: FC = () => { } > Codestin Search App - {organizationPermissionsQuery.isError ? ( + {organizationPermissionsQuery.isLoadingError ? ( ) : !permissions.editDeploymentConfig && !organizationPermissionsQuery.data ? ( ) : ( + {organizationPermissionsQuery.isRefetchError && ( +
+ +
+ )} {organization && ( { permissions.viewAnyMCPServerConfigs } > - {organizationPermissionsQuery.isError ? ( + {organizationPermissionsQuery.isLoadingError ? ( ) : !permissions.editDeploymentConfig && !organizationPermissionsQuery.data ? ( @@ -105,6 +105,11 @@ const UpdateMCPServerPage: FC = () => { + {organizationPermissionsQuery.isRefetchError && ( +
+ +
+ )} {!serverId ? ( ) : serverQuery.isLoading ? ( From ac2df224dce02c09cb130f72a0b1c0aebe00d238 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:40:00 +0000 Subject: [PATCH 32/51] fix(site): redirect MCP-only admins to the MCP servers page --- .../dashboard/Navbar/NavbarView.stories.tsx | 41 ++++++++++++++++++- site/src/router.tsx | 6 ++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 8f6ee1cbc37..c1076fdb422 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -1,14 +1,21 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; import type { TasksFilter } from "#/api/typesGenerated"; +import { AuthProvider } from "#/contexts/auth/AuthProvider"; +import { AISettingsIndexRedirect } from "#/router"; import { MockBuildInfo, + MockNoPermissions, MockTasks, MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; import { pixelWithDesktop, pixelWithTablet } from "#/testHelpers/pixel"; -import { withDashboardProvider } from "#/testHelpers/storybook"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; import { NavbarView } from "./NavbarView"; const tasksFilter: TasksFilter = { @@ -96,9 +103,36 @@ export const ForOrgAdmin: Story = { }; export const ForMCPOrgAdmin: Story = { + decorators: [withAuthProvider], parameters: { pixel: { matrix: pixelWithDesktop }, queries: [{ key: ["tasks", memberTasksFilter], data: [] }], + user: MockUserMember, + permissions: { + ...MockNoPermissions, + viewAnyMCPServerConfigs: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/" }, + routing: [ + { path: "/", useStoryElement: true }, + { + path: "/ai/settings", + // Route elements render outside story decorators, so the + // redirect needs its own AuthProvider; it reads the query + // data seeded by withAuthProvider. + element: ( + + + + ), + }, + { + path: "/ai/settings/mcp-servers", + element:

MCP servers

, + }, + ], + }), }, args: { user: MockUserMember, @@ -112,8 +146,11 @@ export const ForMCPOrgAdmin: Story = { canvas.getByRole("button", { name: "Admin settings" }), ); const body = within(canvasElement.ownerDocument.body); + const aiSettingsLink = body.getByRole("menuitem", { name: "AI" }); + await expect(aiSettingsLink).toHaveAttribute("href", "/ai/settings"); + await userEvent.click(aiSettingsLink); await expect( - body.getByRole("menuitem", { name: "AI" }), + await canvas.findByRole("heading", { name: "MCP servers" }), ).toBeInTheDocument(); }, }; diff --git a/site/src/router.tsx b/site/src/router.tsx index 6be468605ea..de0f1981b99 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -464,7 +464,7 @@ const AISettingsUpdateMCPServerPage = lazy( ), ); -const AISettingsIndexRedirect = () => { +export const AISettingsIndexRedirect = () => { const { permissions } = useAuthenticated(); if (permissions.viewAnyAIProvider) { @@ -479,6 +479,10 @@ const AISettingsIndexRedirect = () => { return ; } + if (permissions.viewAnyMCPServerConfigs) { + return ; + } + return ; }; From 5c41e4929067516a507d925b34229800be8f071e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:20:21 +0000 Subject: [PATCH 33/51] fix(site): let create-only roles add MCP servers --- .../AddMCPServerPage/AddMCPServerPage.tsx | 35 ++++++++---- .../MCPServersPage/MCPServersPage.stories.tsx | 54 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 744357fe6a8..dbf146085ab 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -37,15 +37,19 @@ const AddMCPServerPage: FC = () => { ); const creatableOrganizations = permissions.editDeploymentConfig ? organizations - : viewableOrganizations.filter( + : organizations.filter( (organization) => organizationPermissionsQuery.data?.[organization.id] .createMCPServerConfig, ); const requestedOrganizationName = searchParams.get(orgSearchParam); - const requestedOrganization = viewableOrganizations.find( - (organization) => organization.name === requestedOrganizationName, - ); + const requestedOrganization = + viewableOrganizations.find( + (organization) => organization.name === requestedOrganizationName, + ) ?? + creatableOrganizations.find( + (organization) => organization.name === requestedOrganizationName, + ); const organization = requestedOrganization ?? (creatableOrganizations.length > 0 @@ -54,12 +58,19 @@ const AddMCPServerPage: FC = () => { const organizationPermissions = organization ? organizationPermissionsQuery.data?.[organization.id] : undefined; + const canView = + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.viewMCPServerConfigs); const canCreate = permissions.editDeploymentConfig || - Boolean( - organizationPermissions?.viewMCPServerConfigs && - organizationPermissions.createMCPServerConfig, - ); + Boolean(organizationPermissions?.createMCPServerConfig); + const canOpenServer = + canView && + (permissions.editDeploymentConfig || + Boolean( + organizationPermissions?.updateMCPServerConfig || + organizationPermissions?.deleteMCPServerConfig, + )); const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( @@ -104,7 +115,13 @@ const AddMCPServerPage: FC = () => { try { const server = await createMutation.mutateAsync(req); toast.success(`MCP server "${server.display_name}" added.`); - await navigate(updateMCPServerPath(server.id, organization)); + if (canOpenServer) { + await navigate( + updateMCPServerPath(server.id, organization), + ); + } else if (canView) { + await navigate(mcpServersPath(organization)); + } } catch (error) { toast.error( getErrorMessage(error, "Failed to add MCP server."), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index f8ebabec796..2e93409a4f1 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -419,6 +419,60 @@ export const OrgAdminCanAddMCPServer: Story = { }, }; +export const CreateOnlyOrgAdminCanAddMCPServer: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true, create: true }, + [MockOrganization2.id]: { create: true }, + }); + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockOrganization2MCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { + name: MockOrganization2.display_name, + }), + ); + await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); + await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); + await userEvent.type( + canvas.getByLabelText(/server url/i), + "https://api.githubcopilot.com/mcp/", + ); + await userEvent.click(canvas.getByRole("button", { name: "Add server" })); + await waitFor(() => { + expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( + MockOrganization2.id, + expect.objectContaining({ display_name: "GitHub" }), + ); + }); + await expect(canvas.getByLabelText(/display name/i)).toBeVisible(); + }, +}; + export const AddPermissionsRefetchErrorKeepsForm: Story = { render: () => , decorators: [withRefetchPermissionsProbe], From 506fc18ea98904ff35e1d38ae0514a102cb1533a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:46:41 +0000 Subject: [PATCH 34/51] fix(site): expose MCP add page to create-only administrators Create-only MCP administrators could not reach the add form through navigation, and its back link and cancel button led to the list page that rejects them. Gate navigation on create as well as view, redirect create-only admins to the add page, and omit exit controls when the list is unreachable. --- site/src/modules/dashboard/Navbar/Navbar.tsx | 3 +- .../dashboard/Navbar/NavbarView.stories.tsx | 50 +++++++++++++++++++ .../AISettingsSidebarView.stories.tsx | 15 ++++++ .../management/AISettingsSidebarView.tsx | 11 +++- .../AddMCPServerPage/AddMCPServerPage.tsx | 5 ++ .../AddMCPServerPageView.stories.tsx | 1 + .../AddMCPServerPage/AddMCPServerPageView.tsx | 8 ++- .../MCPServersPage/MCPServersPage.stories.tsx | 6 +++ .../components/MCPServerForm.tsx | 6 ++- .../components/MCPServerFormFields.tsx | 20 ++++---- .../components/MCPServerFormHeader.tsx | 4 +- site/src/router.tsx | 4 ++ 12 files changed, 115 insertions(+), 18 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index b463f12ac57..359fb5c0bc7 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -30,7 +30,8 @@ export const Navbar: React.FC = () => { permissions.viewAnyAIProvider || permissions.viewAIGatewayKeys || permissions.editDeploymentConfig || - permissions.viewAnyMCPServerConfigs; + permissions.viewAnyMCPServerConfigs || + permissions.createAnyMCPServerConfig; const canCreateChat = permissions.createChat; const uniqueLinks = new Map(); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index c1076fdb422..d535926e657 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -155,6 +155,56 @@ export const ForMCPOrgAdmin: Story = { }, }; +export const ForMCPCreateOnlyAdmin: Story = { + decorators: [withAuthProvider], + parameters: { + queries: [{ key: ["tasks", memberTasksFilter], data: [] }], + user: MockUserMember, + permissions: { + ...MockNoPermissions, + createAnyMCPServerConfig: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/" }, + routing: [ + { path: "/", useStoryElement: true }, + { + path: "/ai/settings", + // Route elements render outside story decorators, so the + // redirect needs its own AuthProvider; it reads the query + // data seeded by withAuthProvider. + element: ( + + + + ), + }, + { + path: "/ai/settings/mcp-servers/add", + element:

Add MCP server

, + }, + ], + }), + }, + args: { + user: MockUserMember, + adminPermissions: { + canViewAISettings: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Admin settings" }), + ); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(body.getByRole("menuitem", { name: "AI" })); + await expect( + await canvas.findByRole("heading", { name: "Add MCP server" }), + ).toBeInTheDocument(); + }, +}; + export const ForSingleOrgOSSAdmin: Story = { parameters: { pixel: { matrix: pixelWithDesktop } }, args: { diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index ae31d92e800..6c3d21f9db3 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -112,6 +112,21 @@ export const MCPServersForOrgAdmin: Story = { }, }; +export const MCPServersForCreateOnlyAdmin: Story = { + args: { + permissions: { + ...MockNoPermissions, + createAnyMCPServerConfig: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: "MCP servers" }), + ).toHaveAttribute("href", "/ai/settings/mcp-servers/add"); + }, +}; + export const MCPServersHiddenWithoutPermission: Story = { args: { permissions: { diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 4cc5336e753..948800cd6e7 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -73,9 +73,16 @@ const AISettingsSidebarView: FC = ({ )} {!permissions.editDeploymentConfig && - permissions.viewAnyMCPServerConfigs && ( + (permissions.viewAnyMCPServerConfigs || + permissions.createAnyMCPServerConfig) && (
- + MCP servers
diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index dbf146085ab..7fa86e6bd92 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -71,6 +71,10 @@ const AddMCPServerPage: FC = () => { organizationPermissions?.updateMCPServerConfig || organizationPermissions?.deleteMCPServerConfig, )); + // The list page rejects callers without a site-wide view grant, so exit + // controls pointing at it are only offered when it is reachable. + const canViewServerList = + permissions.editDeploymentConfig || permissions.viewAnyMCPServerConfigs; const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( @@ -101,6 +105,7 @@ const AddMCPServerPage: FC = () => { { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 679a9263a14..c1d1c524736 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -12,6 +12,7 @@ const meta: Meta = { args: { isSaving: false, canCreate: true, + canViewServerList: true, organizations: [MockDefaultOrganization], organization: MockDefaultOrganization, onSelectOrganization: fn(), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index d2de2b52a1a..873058808de 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -9,6 +9,7 @@ import { mcpServersPath } from "../organizationParam"; interface AddMCPServerPageViewProps { isSaving: boolean; canCreate: boolean; + canViewServerList: boolean; organizations: readonly TypesGen.Organization[]; organization: TypesGen.Organization; onSelectOrganization: (organization: TypesGen.Organization) => void; @@ -21,6 +22,7 @@ interface AddMCPServerPageViewProps { const AddMCPServerPageView: FC = ({ isSaving, canCreate, + canViewServerList, organizations, organization, onSelectOrganization, @@ -40,10 +42,12 @@ const AddMCPServerPageView: FC = ({ /> {canCreate ? ( ) : ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 2e93409a4f1..31cc2684d5e 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -457,6 +457,12 @@ export const CreateOnlyOrgAdminCanAddMCPServer: Story = { }), ); await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: /back to mcp servers/i }), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByRole("button", { name: "Cancel" }), + ).not.toBeInTheDocument(); await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); await userEvent.type( canvas.getByLabelText(/server url/i), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index a626a8a12dc..0541dd8919c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -15,7 +15,9 @@ import { type MCPServerFormCreateProps = { server?: undefined; - listPath: string; + // Create-only callers cannot open the server list, so the back link and + // cancel action are omitted rather than pointing at a denied page. + listPath?: string; isSaving: boolean; isDeleting?: false; onCreateServer: ( @@ -24,7 +26,7 @@ type MCPServerFormCreateProps = { onUpdateServer?: undefined; onDeleteServer?: undefined; onToggleEnabled?: undefined; - onCancel: () => void; + onCancel?: () => void; }; type MCPServerFormEditProps = { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx index 3e8f3105643..230953fd19a 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -30,7 +30,7 @@ interface MCPServerFormFieldsProps { isDisabled: boolean; canSubmit: boolean; isEditing: boolean; - onCancel: () => void; + onCancel?: () => void; showDetails: boolean; setShowDetails: (open: boolean) => void; showAuth: boolean; @@ -198,14 +198,16 @@ export const MCPServerFormFields: FC = ({
- + {onCancel && ( + + )} @@ -101,7 +111,12 @@ const MCPServersPageView: FC = ({ description="Add a server to give agents access to external tools." cta={ addOrganization ? ( - diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 31de4a8665c..e26bd6dc37c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -131,7 +131,7 @@ const UpdateMCPServerPage: FC = () => {
- ) : notFound || !server ? ( + ) : notFound || !server || !organization ? ( ) : ( <> @@ -142,6 +142,8 @@ const UpdateMCPServerPage: FC = () => { )} = { component: UpdateMCPServerPageView, args: { server: MockCoderMCPServer, + organizations: [MockDefaultOrganization], + organization: MockDefaultOrganization, listPath: "/ai/settings/mcp-servers", isSaving: false, canSelectUserOIDC: true, @@ -41,6 +44,11 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder"); await userEvent.click( canvas.getByRole("button", { name: /authentication/i }), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index e859e44b0e6..dda7efcdc0d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -2,9 +2,12 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { pageTitle } from "#/utils/page"; import { MCPServerForm } from "../components/MCPServerForm"; +import { OrganizationPicker } from "../components/OrganizationPicker"; interface UpdateMCPServerPageViewProps { server: TypesGen.MCPServerConfig; + organizations: readonly TypesGen.Organization[]; + organization: TypesGen.Organization; listPath: string; isSaving: boolean; isDeleting: boolean; @@ -20,6 +23,8 @@ interface UpdateMCPServerPageViewProps { const UpdateMCPServerPageView: FC = ({ server, + organizations, + organization, listPath, isSaving, isDeleting, @@ -32,6 +37,13 @@ const UpdateMCPServerPageView: FC = ({ return ( <> Codestin Search App + void; + onChange?: (organization: Organization) => void; className?: string; disabled?: boolean; + showSingleOrganization?: boolean; } export const OrganizationPicker: FC = ({ @@ -23,11 +24,12 @@ export const OrganizationPicker: FC = ({ onChange, className, disabled, + showSingleOrganization = false, }) => { - if ( + const hasSingleSelectedOrganization = organizations.length <= 1 && - organizations.some((option) => option.id === organization.id) - ) { + organizations.some((option) => option.id === organization.id); + if (hasSingleSelectedOrganization && !showSingleOrganization) { return null; } @@ -40,12 +42,12 @@ export const OrganizationPicker: FC = ({ value={organization} onChange={(org) => { if (org) { - onChange(org); + onChange?.(org); } }} options={organizations} required - disabled={disabled} + disabled={disabled || !onChange || hasSingleSelectedOrganization} />
); From fad55eb8e42223c25d60557d63bd67f584591ca4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:57:02 +0000 Subject: [PATCH 45/51] test(site): keep MCP add navigation story isolated --- .../MCPServersPage/MCPServersPage.stories.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 9232ed40c42..17d9c935041 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -138,6 +138,11 @@ const ListRedirectProbe: FC = () => { return
list-org:{searchParams.get(orgSearchParam) ?? "none"}
; }; +const AddRedirectProbe: FC = () => { + const [searchParams] = useSearchParams(); + return

add-org:{searchParams.get(orgSearchParam) ?? "none"}

; +}; + const DetailRedirectProbe: FC = () => { const [searchParams] = useSearchParams(); return

detail-org:{searchParams.get(orgSearchParam) ?? "none"}

; @@ -366,7 +371,7 @@ export const ListCanAddToCreateOnlyOrganization: Story = { { path: "/ai/settings/mcp-servers", useStoryElement: true }, { path: "/ai/settings/mcp-servers/add", - element: , + element: , }, ], }), @@ -397,8 +402,8 @@ export const ListCanAddToCreateOnlyOrganization: Story = { ); await userEvent.click(addButton); await expect( - await canvas.findByRole("button", { - name: `Organization ${MockOrganization2.display_name}`, + await canvas.findByRole("heading", { + name: `add-org:${MockOrganization2.name}`, }), ).toBeVisible(); }, From 9c8c8510da970e75fd1449c461d953fb6be8265b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:53:37 +0000 Subject: [PATCH 46/51] fix(coderd): admit MCP managers to audit resource links --- coderd/audit.go | 15 ++++++++++----- coderd/audit_test.go | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 9d04b08ee78..02ee5088f2d 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -23,7 +23,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" @@ -620,14 +619,20 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit // not username-scoped in the URL like workspaces or tasks. return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) case database.ResourceTypeMCPServerConfig: + config, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID) + if err != nil { + return "" + } actor, ok := dbauthz.ActorFromContext(ctx) if !ok { return "" } - // The MCP settings page admits only deployment-config managers, - // so emit the link only for callers the page will accept. - if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return "" + // The MCP settings page admits anyone who can manage the + // config, so emit the link only for callers it will accept. + if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, config.RBACObject()); err != nil { + if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionDelete, config.RBACObject()); err != nil { + return "" + } } organization, err := api.Database.GetOrganizationByID(ctx, alog.AuditLog.OrganizationID) if err != nil { diff --git a/coderd/audit_test.go b/coderd/audit_test.go index fdae1e87817..43abd96dc6f 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -178,8 +178,8 @@ func TestAuditLogs(t *testing.T) { require.Len(t, auditorLogs.AuditLogs, 1) require.Empty(t, auditorLogs.AuditLogs[0].ResourceLink) - // Organization admins hold MCP permissions but not the - // deployment-config access the settings page requires. + // Organization admins hold MCP management permissions, and the + // settings page now admits them. orgAdminLogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ Limit: 1, @@ -187,7 +187,7 @@ func TestAuditLogs(t *testing.T) { }) require.NoError(t, err) require.Len(t, orgAdminLogs.AuditLogs, 1) - require.Empty(t, orgAdminLogs.AuditLogs[0].ResourceLink) + require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), orgAdminLogs.AuditLogs[0].ResourceLink) ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ From 71d1def839612705e5a2a6c3cff396dd88fae339 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:57:32 +0000 Subject: [PATCH 47/51] fix(site): pin implicit MCP create target in the URL --- .../AddMCPServerPage/AddMCPServerPage.tsx | 10 ++- .../MCPServersPage/MCPServersPage.stories.tsx | 73 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 09724269082..229e87f1722 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { type FC, useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; @@ -61,6 +61,14 @@ const AddMCPServerPage: FC = () => { (requestedOrganizationName === null && creatableOrganizations.length > 0 ? selectOrganization(creatableOrganizations, null) : undefined); + useEffect(() => { + if (requestedOrganizationName !== null || !organization) { + return; + } + const next = new URLSearchParams(searchParams); + next.set(orgSearchParam, organization.name); + setSearchParams(next, { replace: true }); + }, [organization, requestedOrganizationName, searchParams, setSearchParams]); const organizationPermissions = organization ? organizationPermissionsQuery.data?.[organization.id] : undefined; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 17d9c935041..d373a1b7091 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -133,6 +133,22 @@ const withRefetchPermissionsProbe: Decorator = (Story) => ( ); +const OrganizationSearchParamProbe: FC = () => { + const [searchParams] = useSearchParams(); + return ( + + {searchParams.get(orgSearchParam) ?? "none"} + + ); +}; + +const withOrganizationSearchParamProbe: Decorator = (Story) => ( + <> + + + +); + const ListRedirectProbe: FC = () => { const [searchParams] = useSearchParams(); return
list-org:{searchParams.get(orgSearchParam) ?? "none"}
; @@ -684,6 +700,63 @@ export const AddDeniedRequestedOrganizationDoesNotFallback: Story = { }, }; +export const AddImplicitOrganizationDeniedAfterPermissionsRefetch: Story = { + render: () => , + decorators: [withRefetchPermissionsProbe, withOrganizationSearchParamProbe], + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + const checkAuthorization = mockOrganizationPermissions({ + [MockDefaultOrganization.id]: {}, + [MockOrganization2.id]: { create: true }, + }); + checkAuthorization.mockImplementationOnce(async ({ checks }) => + organizationPermissionsResponse( + { + [MockDefaultOrganization.id]: { create: true }, + [MockOrganization2.id]: { create: true }, + }, + checks, + ), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.type( + await canvas.findByLabelText(/display name/i), + "Draft server", + ); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch permissions" }), + ); + await expect( + await body.findByText("You don't have permission to view this page"), + ).toBeInTheDocument(); + expect(canvas.queryByLabelText(/display name/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }), + ).not.toBeInTheDocument(); + await expect( + canvas.getByLabelText("Organization query parameter"), + ).toHaveTextContent(MockDefaultOrganization.name); + }, +}; + export const AddFailurePreservesEnteredValues: Story = { render: () => , parameters: { From d50c2d9b6d398a1be1eb705984e45a9128312343 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:32:29 +0000 Subject: [PATCH 48/51] fix(coderd): keep MCP audit links for mutation-only admins --- coderd/audit.go | 5 ++- enterprise/coderd/audit_test.go | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/coderd/audit.go b/coderd/audit.go index 02ee5088f2d..84fa32d29c5 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -619,7 +619,10 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit // not username-scoped in the URL like workspaces or tasks. return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) case database.ResourceTypeMCPServerConfig: - config, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID) + // Mutation-only administrators may lack read access, so fetch + // with system access; the explicit checks below authorize. + //nolint:gocritic // The requester is authorized against the fetched config right below. + config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), alog.AuditLog.ResourceID) if err != nil { return "" } diff --git a/enterprise/coderd/audit_test.go b/enterprise/coderd/audit_test.go index 27167149186..683d2b1bf55 100644 --- a/enterprise/coderd/audit_test.go +++ b/enterprise/coderd/audit_test.go @@ -2,14 +2,18 @@ package coderd_test import ( "context" + "fmt" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" ) func TestEnterpriseAuditLogs(t *testing.T) { @@ -102,4 +106,60 @@ func TestEnterpriseAuditLogs(t *testing.T) { // OrganizationID is deprecated, but make sure it is empty. require.Equal(t, uuid.Nil, alogs.AuditLogs[0].OrganizationID) }) + + t.Run("MCPServerConfigLinkForMutationOnlyRole", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + owner, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + config := createMCPServerConfigForOrganization(t, owner, firstUser.OrganizationID, "audit-link-mcp") + // Remove the default everyone-in-org ACL grant so the custom + // role's update permission is the requester's only access. + //nolint:gocritic // Owner access removes the default ACL grant. + err := owner.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{ + GroupRoles: map[string]codersdk.MCPServerConfigRole{ + firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted, + }, + }) + require.NoError(t, err) + + //nolint:gocritic // Owner access isolates custom-role setup from the behavior under test. + role, err := owner.CreateOrganizationRole(ctx, codersdk.Role{ + Name: "mcp-update-only-audit", + OrganizationID: firstUser.OrganizationID.String(), + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceMCPServerConfig: {codersdk.ActionUpdate}, + codersdk.ResourceAuditLog: {codersdk.ActionRead}, + }), + }) + require.NoError(t, err) + updateOnly, _ := coderdtest.CreateAnotherUser(t, owner, firstUser.OrganizationID, + rbac.RoleIdentifier{Name: role.Name, OrganizationID: firstUser.OrganizationID}) + + err = owner.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + Action: codersdk.AuditActionCreate, + ResourceType: codersdk.ResourceTypeMCPServerConfig, + ResourceID: config.ID, + OrganizationID: firstUser.OrganizationID, + }) + require.NoError(t, err) + + // Update-only administrators lack config read access, yet the + // settings page admits them, so the link must still appear. + logs, err := updateOnly.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Len(t, logs.AuditLogs, 1) + require.Contains(t, logs.AuditLogs[0].ResourceLink, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID)) + }) } From 7cada68fb5067f78f7485f3ac842034ece5442d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:55:41 +0000 Subject: [PATCH 49/51] fix(site): disambiguate create-only MCP add targets --- .../agents/platform-controls/mcp-servers.md | 6 ++- .../MCPServersPage/MCPServersPage.stories.tsx | 47 +++++++++++++++++++ .../MCPServersPage/MCPServersPage.tsx | 1 + .../MCPServersPage/MCPServersPageView.tsx | 6 ++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index aef833e1200..d7e8969e4ec 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -8,8 +8,10 @@ availability policy. This is an admin-only feature accessible at **AI Settings** > **Coder Agents** > **MCP servers** (`/ai/settings/mcp-servers`). In multi-organization deployments, use the -organization picker to choose which organization's servers to manage. The picker -only appears when the deployment has more than one organization. +organization picker to choose which organization's servers to manage. The +server list shows the picker when you can access more than one organization's +servers. The add and update views always show the target organization, as a +read-only field when only one organization is available. ## Add an MCP server diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index d373a1b7091..6363fd91d74 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -425,6 +425,53 @@ export const ListCanAddToCreateOnlyOrganization: Story = { }, }; +export const ListDisambiguatesCollidingCreateOnlyTargets: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + organizations: [ + MockDefaultOrganization, + { + ...MockOrganization2, + name: "create-b", + display_name: "Create Target", + }, + { + ...MockOrganization3, + name: "create-c", + display_name: "Create Target", + }, + ], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true }, + [MockOrganization2.id]: { create: true }, + [MockOrganization3.id]: { create: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + const addButton = canvas.getByRole("button", { + name: /^Add server to Create Target \((create-b|create-c)\)$/, + }); + await expect(addButton).toBeVisible(); + }, +}; + export const AddDeepLinkShowsSingleCreatableOrganization: Story = { render: () => , parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index d2b3a2327d0..b7fb6fe0f58 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -105,6 +105,7 @@ const MCPServersPage: FC = () => { organizations={authorizedOrganizations} organization={organization} addOrganization={addOrganization} + addOrganizations={creatableOrganizations} canOpenServer={canOpenServer} onSelectOrganization={(org) => { setSearchParams((params) => { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index 27d4561137e..a8cea3b6ee6 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -30,6 +30,7 @@ interface MCPServersPageViewProps { organizations: readonly TypesGen.Organization[]; organization: TypesGen.Organization; addOrganization?: TypesGen.Organization; + addOrganizations: readonly TypesGen.Organization[]; canOpenServer: boolean; onSelectOrganization: (organization: TypesGen.Organization) => void; } @@ -41,13 +42,16 @@ const MCPServersPageView: FC = ({ organizations, organization, addOrganization, + addOrganizations, canOpenServer, onSelectOrganization, }) => { const navigate = useNavigate(); + // Disambiguate against creation targets: colliding display names + // among create-only organizations never appear in the list picker. const addButtonLabel = addOrganization && addOrganization.id !== organization.id - ? `Add server to ${getOrganizationLabel(addOrganization, organizations)}` + ? `Add server to ${getOrganizationLabel(addOrganization, addOrganizations)}` : undefined; const goToAddServer = () => { if (addOrganization) { From 6c964b9ed95769b27e9053ca38a75bc8a022fefa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:12:55 +0000 Subject: [PATCH 50/51] fix(site): disambiguate MCP add target from the selected organization --- .../MCPServersPage/MCPServersPage.stories.tsx | 40 +++++++++++++++++++ .../MCPServersPage/MCPServersPageView.tsx | 9 +++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 6363fd91d74..409526712a5 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -472,6 +472,46 @@ export const ListDisambiguatesCollidingCreateOnlyTargets: Story = { }, }; +export const ListDisambiguatesAddTargetFromSelectedOrganization: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + organizations: [ + MockDefaultOrganization, + { + ...MockOrganization2, + display_name: MockDefaultOrganization.display_name, + }, + ], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { view: true }, + [MockOrganization2.id]: { create: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + const addButton = canvas.getByRole("button", { + name: `Add server to ${MockDefaultOrganization.display_name} (${MockOrganization2.name})`, + }); + await expect(addButton).toBeVisible(); + }, +}; + export const AddDeepLinkShowsSingleCreatableOrganization: Story = { render: () => , parameters: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index a8cea3b6ee6..a81e5217792 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -47,11 +47,14 @@ const MCPServersPageView: FC = ({ onSelectOrganization, }) => { const navigate = useNavigate(); - // Disambiguate against creation targets: colliding display names - // among create-only organizations never appear in the list picker. + // Disambiguate against every organization sharing the page context: + // other creation targets and the currently selected organization. const addButtonLabel = addOrganization && addOrganization.id !== organization.id - ? `Add server to ${getOrganizationLabel(addOrganization, addOrganizations)}` + ? `Add server to ${getOrganizationLabel(addOrganization, [ + ...addOrganizations, + organization, + ])}` : undefined; const goToAddServer = () => { if (addOrganization) { From adbe7c918b5bf9e941fee19c4383e051eacebdab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:55:37 +0000 Subject: [PATCH 51/51] fix(site/src): include the selected list-only org when disambiguating MCP picker options --- .../OrganizationAutocomplete.stories.tsx | 35 +++++++++++++++++++ .../OrganizationAutocomplete.tsx | 10 ++++-- .../components/OrganizationPicker.tsx | 12 ++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx index 2cd04761d27..307b670df0a 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx @@ -63,6 +63,41 @@ export const CollidingDisplayNames: Story = { }, }; +// Collides with an option but is not itself selectable, mirroring a +// deep link to a list-only organization. +const listOnlyOrganization = { + ...MockOrganization, + id: "org-t", + name: "org-t", + display_name: "Dev", +}; + +export const NonOptionValueDisambiguatesOptions: Story = { + args: { + value: listOnlyOrganization, + options: collidingDisplayNameOrganizations.slice(0, 1), + labelOrganizations: [ + ...collidingDisplayNameOrganizations.slice(0, 1), + listOnlyOrganization, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Dev \(org-t\)/ }), + ).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button")); + await waitFor(() => { + expect( + screen.getByRole("option", { name: "Dev (org-a)" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: /org-t/ }), + ).not.toBeInTheDocument(); + }); + }, +}; + export const FallbackNameCollision: Story = { args: { value: null, diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index a1a0a4acef6..ff48d28345b 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -22,6 +22,10 @@ type OrganizationAutocompleteProps = { value: Organization | null; onChange: (organization: Organization | null) => void; options: readonly Organization[]; + // Collision set for disambiguation labels. Lets callers include + // organizations that are visible but not selectable, such as a + // selected organization missing from options. Defaults to options. + labelOrganizations?: readonly Organization[]; id?: string; ariaLabel?: string; required?: boolean; @@ -49,12 +53,14 @@ export const OrganizationAutocomplete: FC = ({ value, onChange, options, + labelOrganizations, id, ariaLabel, required, disabled, }) => { const [open, setOpen] = useState(false); + const labelContext = labelOrganizations ?? options; return ( @@ -77,7 +83,7 @@ export const OrganizationAutocomplete: FC = ({ fallback={value.display_name} /> - {getOrganizationLabel(value, options)} + {getOrganizationLabel(value, labelContext)} ) : ( @@ -112,7 +118,7 @@ export const OrganizationAutocomplete: FC = ({ fallback={org.display_name} /> - {getOrganizationLabel(org, options)} + {getOrganizationLabel(org, labelContext)} {value?.id === org.id && ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index b3523de67e8..08444583a68 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -33,12 +33,21 @@ export const OrganizationPicker: FC = ({ return null; } + // The selected organization can fall outside the selectable options, + // such as a deep link to an organization where servers are listable + // but not creatable, so include it when disambiguating labels. + const labelOrganizations = organizations.some( + (option) => option.id === organization.id, + ) + ? organizations + : [...organizations, organization]; + return (
{ if (org) { @@ -46,6 +55,7 @@ export const OrganizationPicker: FC = ({ } }} options={organizations} + labelOrganizations={labelOrganizations} required disabled={disabled || !onChange || hasSingleSelectedOrganization} />