feat: add organization picker to MCP server settings pages (#28150)

Adds an organization picker to the MCP server settings pages so
multi-org deployments can manage every organization's MCP servers
through the UI, not just the default organization's. Closes CODAGT-714
(scoped to MCP servers only; model configs stay deployment-wide until
CODAGT-709 lands).

## Stack Context

Top of the CODAGT-711/717/712/806 stack: MCP server configs are
org-scoped with RBAC, audit logging, per-config ACLs, and owner-scoped
user tokens. This layer makes the admin UI org-aware, resolving the
"default org only" limitation documented in the bottom PR.

## Summary

- The MCP servers list and add pages resolve the working organization
from an `org` search param (falling back to the default org), and render
an organization picker built on the existing `OrganizationAutocomplete`.
The picker only appears when more than one organization is visible, so
single-org deployments see no change.
- Selecting an organization rewrites the search param, refetches the
list for that org, and threads through to the add page so creation posts
to the selected organization.
- The update page now loads the server by ID (new organization-scoped
`GET /api/experimental/organizations/{organization}/mcp-servers/{id}`
frontend binding) instead of listing the default org and searching
client-side, so servers from any organization can be edited. Post-save
navigation returns to the owning organization's list via the server's
`organization_id`.
- Removes the now-unused `getDefaultOrganizationId` helper.
- Review hardening: organization display labels are disambiguated with
the unique org name when effective display names collide (picker
options, trigger text, and accessible name). Access is no longer
restricted to deployment admins: the pages, sidebar item, and nav entry
accept org-scoped `mcp_server_config` permissions, the picker lists only
organizations the user can view, content and affordances (add, edit,
delete) are gated on the selected organization's permissions, and
deployment admins keep full access everywhere.

## Testing

Storybook interaction stories cover: picker hidden with one org,
switching orgs refetches and swaps list contents, add posting to the
URL-selected org, add posting after an in-form org switch (asserting
typed form state survives the switch), and update loading by ID.
Red-green verified by breaking the search-param resolution and observing
the new stories fail.

> Mux acted on Mike's behalf for this PR.
This commit is contained in:
Michael Suchacz
2026-08-19 19:46:23 +00:00
committed by GitHub
parent 8203b2ebaf
commit 24b83dc727
34 changed files with 2983 additions and 196 deletions
+18 -5
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -22,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"
@@ -619,16 +619,29 @@ 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:
// 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 ""
}
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 {
// 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 {
return ""
}
return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID)
return fmt.Sprintf("/ai/settings/mcp-servers/%s?org=%s", alog.AuditLog.ResourceID, url.QueryEscape(organization.Name))
case database.ResourceTypeUserSecret:
// TODO(PLAT-102): point at the user secrets management page once
// it ships. Until then, the audit row links nowhere.
+11 -7
View File
@@ -152,16 +152,20 @@ func TestAuditLogs(t *testing.T) {
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps})
user := coderdtest.CreateFirstUser(t, client)
auditor, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleAuditor())
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID))
organization := dbgen.Organization(t, db, database.Organization{
Name: "mcp-audit-link-org",
})
require.NotEqual(t, user.OrganizationID, organization.ID)
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, organization.ID, rbac.ScopedRoleOrgAdmin(organization.ID))
config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: user.OrganizationID,
OrganizationID: organization.ID,
})
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
Action: codersdk.AuditActionCreate,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: config.ID,
OrganizationID: user.OrganizationID,
OrganizationID: organization.ID,
})
require.NoError(t, err)
@@ -174,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,
@@ -183,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{
@@ -192,7 +196,7 @@ func TestAuditLogs(t *testing.T) {
})
require.NoError(t, err)
require.Len(t, ownerLogs.AuditLogs, 1)
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
})
t.Run("Organization", func(t *testing.T) {
@@ -7,8 +7,11 @@ 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
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
+60
View File
@@ -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))
})
}
+16
View File
@@ -91,6 +91,22 @@
"object": { "resource_type": "idpsync_settings", "any_org": true },
"action": "read"
},
"viewAnyMCPServerConfigs": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "read"
},
"createAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "create"
},
"updateAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "update"
},
"deleteAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "delete"
},
"editAnySettings": {
"object": { "resource_type": "organization", "any_org": true },
"action": "update"
+10
View File
@@ -3921,6 +3921,16 @@ class ExperimentalApiMethods {
return response.data;
};
getMCPServerConfig = async (
organization: string,
id: string,
): Promise<TypesGen.MCPServerConfig> => {
const response = await this.axios.get<TypesGen.MCPServerConfig>(
mcpServerConfigPath(organization, id),
);
return response.data;
};
createMCPServerConfig = async (
organization: string,
req: TypesGen.CreateMCPServerConfigRequest,
+9
View File
@@ -2336,6 +2336,15 @@ export const mcpServerConfigs = (organization: string) => ({
API.experimental.getMCPServerConfigs(organization),
});
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<TypesGen.MCPServerConfig> =>
API.experimental.getMCPServerConfig(organization, id),
});
const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => {
await queryClient.invalidateQueries({ queryKey: mcpServersKey });
};
@@ -34,6 +34,92 @@ export const ManyOrgs: Story = {
},
};
const collidingDisplayNameOrganizations = [
{ ...MockOrganization, name: "org-a", display_name: "Dev" },
{ ...MockOrganization2, name: "org-b", display_name: "Dev" },
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
];
export const CollidingDisplayNames: Story = {
args: {
value: collidingDisplayNameOrganizations[0],
options: collidingDisplayNameOrganizations,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /Dev \(org-a\)/ }),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(
screen.getByRole("option", { name: "Dev (org-a)" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Dev (org-b)" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
});
},
};
// 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,
options: [
{ ...MockOrganization, name: "dev", display_name: "" },
{ ...MockOrganization2, name: "org-b", display_name: "dev" },
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(screen.getByRole("option", { name: /^dev$/ })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "dev (org-b)" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
});
},
};
export const WithValue: Story = {
args: {
value: MockOrganization2,
@@ -22,25 +22,54 @@ 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;
disabled?: boolean;
};
export const getOrganizationLabel = (
organization: Organization,
organizations: readonly Organization[],
) => {
const displayName = organization.display_name || organization.name;
const hasCollidingDisplayName = organizations.some(
(other) =>
other.id !== organization.id &&
(other.display_name || other.name) === displayName,
);
if (hasCollidingDisplayName && organization.name !== displayName) {
return `${displayName} (${organization.name})`;
}
return displayName;
};
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
value,
onChange,
options,
labelOrganizations,
id,
ariaLabel,
required,
disabled,
}) => {
const [open, setOpen] = useState(false);
const labelContext = labelOrganizations ?? options;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
aria-label={ariaLabel}
variant="outline"
disabled={disabled}
aria-expanded={open}
aria-required={required}
data-testid="organization-autocomplete"
@@ -53,7 +82,9 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
src={value.icon}
fallback={value.display_name}
/>
<span className="truncate">{value.display_name}</span>
<span className="truncate">
{getOrganizationLabel(value, labelContext)}
</span>
</>
) : (
<span className="text-content-secondary">
@@ -87,7 +118,7 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
fallback={org.display_name}
/>
<span className="truncate">
{org.display_name || org.name}
{getOrganizationLabel(org, labelContext)}
</span>
{value?.id === org.id && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
+5 -1
View File
@@ -29,7 +29,11 @@ export const Navbar: React.FC = () => {
const canViewAISettings =
permissions.viewAnyAIProvider ||
permissions.viewAIGatewayKeys ||
permissions.editDeploymentConfig;
permissions.editDeploymentConfig ||
permissions.viewAnyMCPServerConfigs ||
permissions.createAnyMCPServerConfig ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig;
const canCreateChat = permissions.createChat;
const uniqueLinks = new Map<string, LinkConfig>();
@@ -1,20 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
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 = {
owner: MockUserOwner.username,
};
const memberTasksFilter: TasksFilter = {
owner: MockUserMember.username,
};
const meta: Meta<typeof NavbarView> = {
title: "modules/dashboard/NavbarView",
parameters: {
@@ -91,6 +102,156 @@ export const ForOrgAdmin: Story = {
},
};
export const ForMCPUpdateOnlyAdmin: Story = {
decorators: [withAuthProvider],
parameters: {
pixel: { matrix: pixelWithDesktop },
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
user: MockUserMember,
permissions: {
...MockNoPermissions,
updateAnyMCPServerConfig: 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: (
<AuthProvider>
<AISettingsIndexRedirect />
</AuthProvider>
),
},
{
path: "/ai/settings/mcp-servers",
element: <h1>MCP servers</h1>,
},
],
}),
},
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);
const aiSettingsLink = body.getByRole("menuitem", { name: "AI" });
await expect(aiSettingsLink).toHaveAttribute("href", "/ai/settings");
await userEvent.click(aiSettingsLink);
await expect(
await canvas.findByRole("heading", { name: "MCP servers" }),
).toBeInTheDocument();
},
};
export const ForMCPDeleteOnlyAdmin: Story = {
decorators: [withAuthProvider],
parameters: {
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
user: MockUserMember,
permissions: {
...MockNoPermissions,
deleteAnyMCPServerConfig: true,
},
reactRouter: reactRouterParameters({
location: { path: "/" },
routing: [
{ path: "/", useStoryElement: true },
{
path: "/ai/settings",
element: (
<AuthProvider>
<AISettingsIndexRedirect />
</AuthProvider>
),
},
{
path: "/ai/settings/mcp-servers",
element: <h1>MCP servers</h1>,
},
],
}),
},
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: "MCP servers" }),
).toBeInTheDocument();
},
};
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: (
<AuthProvider>
<AISettingsIndexRedirect />
</AuthProvider>
),
},
{
path: "/ai/settings/mcp-servers/add",
element: <h1>Add MCP server</h1>,
},
],
}),
},
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: {
@@ -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 ?? "";
@@ -97,6 +97,67 @@ export const NoUpdateTemplates: Story = {
},
};
export const MCPServersForUpdateOnlyAdmin: Story = {
args: {
permissions: {
...MockNoPermissions,
updateAnyMCPServerConfig: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("link", { name: "MCP servers" }),
).toBeVisible();
},
};
export const MCPServersForDeleteOnlyAdmin: Story = {
args: {
permissions: {
...MockNoPermissions,
deleteAnyMCPServerConfig: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("link", { name: "MCP servers" }),
).toHaveAttribute("href", "/ai/settings/mcp-servers");
},
};
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: {
...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,
@@ -72,6 +72,25 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
</div>
</>
)}
{!permissions.editDeploymentConfig &&
(permissions.viewAnyMCPServerConfigs ||
permissions.createAnyMCPServerConfig ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig) && (
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem
href={
permissions.viewAnyMCPServerConfigs ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig
? "/ai/settings/mcp-servers"
: "/ai/settings/mcp-servers/add"
}
>
MCP servers
</SubNavItem>
</div>
)}
</div>
</BaseSidebar>
);
@@ -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<string, AuthorizationCheck>;
/**
@@ -1,42 +1,163 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { type FC, useEffect } from "react";
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 {
getDefaultOrganizationId,
useDashboard,
} from "#/modules/dashboard/useDashboard";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import {
mcpServersPath,
orgSearchParam,
selectOrganization,
updateMCPServerPath,
} from "../organizationParam";
import AddMCPServerPageView from "./AddMCPServerPageView";
const AddMCPServerPage: FC = () => {
const { permissions } = useAuthenticated();
const { organizations } = useDashboard();
const organization = getDefaultOrganizationId(organizations);
const [searchParams, setSearchParams] = useSearchParams();
const organizationPermissionsQuery = useQuery({
...organizationsPermissions(
organizations.map((organization) => organization.id),
),
enabled: !permissions.editDeploymentConfig,
});
const listableOrganizations = permissions.editDeploymentConfig
? organizations
: organizations.filter((organization) => {
const organizationPermissions =
organizationPermissionsQuery.data?.[organization.id];
return Boolean(
organizationPermissions?.viewMCPServerConfigs ||
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
});
const creatableOrganizations = permissions.editDeploymentConfig
? organizations
: organizations.filter(
(organization) =>
organizationPermissionsQuery.data?.[organization.id]
.createMCPServerConfig,
);
const requestedOrganizationName = searchParams.get(orgSearchParam);
const requestedOrganization =
listableOrganizations.find(
(organization) => organization.name === requestedOrganizationName,
) ??
creatableOrganizations.find(
(organization) => organization.name === requestedOrganizationName,
);
const requestedOrganizationDenied =
requestedOrganizationName !== null && !requestedOrganization;
const organization =
requestedOrganization ??
(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;
const canViewServerList =
permissions.editDeploymentConfig ||
Boolean(
organizationPermissions?.viewMCPServerConfigs ||
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
const canCreate =
permissions.editDeploymentConfig ||
Boolean(organizationPermissions?.createMCPServerConfig);
const canOpenServer =
permissions.editDeploymentConfig ||
Boolean(
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
const canOpenAnyServerList =
permissions.editDeploymentConfig ||
permissions.viewAnyMCPServerConfigs ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig;
const queryClient = useQueryClient();
const navigate = useNavigate();
const createMutation = useMutation(
createMCPServerConfig(queryClient, organization),
createMCPServerConfig(queryClient, organization?.id ?? ""),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AddMCPServerPageView
isSaving={createMutation.isPending}
onCancel={() => void navigate("/ai/settings/mcp-servers")}
onCreateServer={async (req) => {
try {
const server = await createMutation.mutateAsync(req);
toast.success(`MCP server "${server.display_name}" added.`);
await navigate(`/ai/settings/mcp-servers/${server.id}`);
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add MCP server."));
}
}}
/>
<RequirePermission
isFeatureVisible={
permissions.editDeploymentConfig || permissions.createAnyMCPServerConfig
}
>
{organizationPermissionsQuery.isLoadingError ? (
<ErrorAlert error={organizationPermissionsQuery.error} />
) : !permissions.editDeploymentConfig &&
!organizationPermissionsQuery.data ? (
<Loader />
) : creatableOrganizations.length === 0 || requestedOrganizationDenied ? (
<RequirePermission isFeatureVisible={false} />
) : (
organization && (
<>
{organizationPermissionsQuery.isRefetchError && (
<div className="mb-4">
<ErrorAlert error={organizationPermissionsQuery.error} />
</div>
)}
<AddMCPServerPageView
isSaving={createMutation.isPending}
canCreate={canCreate}
canSelectUserOIDC={permissions.editDeploymentConfig}
canViewServerList={canOpenAnyServerList}
organizations={creatableOrganizations}
organization={organization}
onSelectOrganization={(org) => {
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.`);
if (canOpenServer) {
await navigate(
updateMCPServerPath(server.id, organization),
);
} else if (canViewServerList) {
await navigate(mcpServersPath(organization));
}
return true;
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to add MCP server."),
);
return false;
}
}}
/>
</>
)
)}
</RequirePermission>
);
};
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
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<typeof AddMCPServerPageView> = {
@@ -10,9 +10,14 @@ const meta: Meta<typeof AddMCPServerPageView> = {
component: AddMCPServerPageView,
args: {
isSaving: false,
canCreate: true,
canSelectUserOIDC: true,
canViewServerList: true,
organizations: [MockDefaultOrganization],
organization: MockDefaultOrganization,
onSelectOrganization: fn(),
onCreateServer: fn(
async (req: TypesGen.CreateMCPServerConfigRequest) =>
({ ...MockMCPServerConfig, ...req }) as TypesGen.MCPServerConfig,
async (_req: TypesGen.CreateMCPServerConfigRequest) => true,
),
onCancel: fn(),
},
@@ -32,6 +37,11 @@ export const Default: Story = {
const canvas = within(canvasElement);
const addButton = canvas.getByRole("button", { name: "Add server" });
await expect(
canvas.getByRole("button", {
name: `Organization ${MockDefaultOrganization.display_name}`,
}),
).toBeVisible();
await expect(addButton).toBeDisabled();
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("github");
@@ -62,5 +72,31 @@ export const Default: Story = {
}),
);
});
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("");
await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("");
await expect(canvas.getByLabelText(/server url/i)).toHaveValue("");
await expect(addButton).toBeDisabled();
},
};
export const UserOIDCRequiresDeploymentPermission: Story = {
args: {
canSelectUserOIDC: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
canvas.getByRole("button", { name: /authentication/i }),
);
await userEvent.click(
canvas.getByRole("combobox", { name: /authentication method/i }),
);
await expect(
body.getByRole("option", { name: "OAuth2" }),
).toBeInTheDocument();
expect(
body.queryByRole("option", { name: "User OIDC identity" }),
).not.toBeInTheDocument();
},
};
@@ -1,10 +1,19 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { pageTitle } from "#/utils/page";
import { MCPServerForm } from "../components/MCPServerForm";
import { OrganizationPicker } from "../components/OrganizationPicker";
import { mcpServersPath } from "../organizationParam";
interface AddMCPServerPageViewProps {
isSaving: boolean;
canCreate: boolean;
canViewServerList: boolean;
canSelectUserOIDC: boolean;
organizations: readonly TypesGen.Organization[];
organization: TypesGen.Organization;
onSelectOrganization: (organization: TypesGen.Organization) => void;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
) => Promise<unknown>;
@@ -13,17 +22,45 @@ interface AddMCPServerPageViewProps {
const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
isSaving,
canCreate,
canViewServerList,
canSelectUserOIDC,
organizations,
organization,
onSelectOrganization,
onCreateServer,
onCancel,
}) => {
return (
<>
<title>{pageTitle("Add server", "AI Settings")}</title>
<MCPServerForm
isSaving={isSaving}
onCreateServer={onCreateServer}
onCancel={onCancel}
<OrganizationPicker
id="mcp-add-organization"
className="mb-6"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
disabled={isSaving}
showSingleOrganization
/>
{canCreate ? (
<MCPServerForm
listPath={
canViewServerList ? mcpServersPath(organization) : undefined
}
isSaving={isSaving}
canSelectUserOIDC={canSelectUserOIDC}
onCreateServer={onCreateServer}
onCancel={canViewServerList ? onCancel : undefined}
/>
) : (
<Alert severity="error" prominent>
<AlertTitle>You cannot add servers to this organization</AlertTitle>
<AlertDescription>
Choose an organization where you have permission to add MCP servers.
</AlertDescription>
</Alert>
)}
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,35 +1,123 @@
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 {
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 organizationPermissionsQuery = useQuery({
...organizationsPermissions(
organizations.map((organization) => organization.id),
),
enabled: !permissions.editDeploymentConfig,
});
const authorizedOrganizations = permissions.editDeploymentConfig
? organizations
: organizations.filter((organization) => {
const organizationPermissions =
organizationPermissionsQuery.data?.[organization.id];
return Boolean(
organizationPermissions?.viewMCPServerConfigs ||
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
});
const creatableOrganizations = permissions.editDeploymentConfig
? organizations
: organizations.filter(
(organization) =>
organizationPermissionsQuery.data?.[organization.id]
.createMCPServerConfig,
);
const organization =
authorizedOrganizations.length > 0
? selectOrganization(
authorizedOrganizations,
searchParams.get(orgSearchParam),
)
: undefined;
const addOrganization =
creatableOrganizations.length > 0
? selectOrganization(creatableOrganizations, organization?.name ?? null)
: undefined;
const organizationPermissions = organization
? organizationPermissionsQuery.data?.[organization.id]
: undefined;
const canView =
permissions.editDeploymentConfig ||
Boolean(
organizationPermissions?.viewMCPServerConfigs ||
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
const canOpenServer =
permissions.editDeploymentConfig ||
Boolean(
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
const serversQuery = useQuery({
...mcpServerConfigs(organization),
enabled: Boolean(organization),
...mcpServerConfigs(organization?.id ?? ""),
enabled: canView,
});
const servers = (serversQuery.data ?? []).toSorted((a, b) =>
a.display_name.localeCompare(b.display_name),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<RequirePermission
isFeatureVisible={
permissions.editDeploymentConfig ||
permissions.viewAnyMCPServerConfigs ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig
}
>
<title>{pageTitle("MCP servers", "AI Settings")}</title>
<MCPServersPageView
isLoading={serversQuery.isLoading}
error={serversQuery.error}
servers={servers}
/>
{organizationPermissionsQuery.isLoadingError ? (
<ErrorAlert error={organizationPermissionsQuery.error} />
) : !permissions.editDeploymentConfig &&
!organizationPermissionsQuery.data ? (
<Loader />
) : (
<RequirePermission isFeatureVisible={canView && Boolean(organization)}>
{organizationPermissionsQuery.isRefetchError && (
<div className="mb-4">
<ErrorAlert error={organizationPermissionsQuery.error} />
</div>
)}
{organization && (
<MCPServersPageView
isLoading={serversQuery.isLoading}
error={serversQuery.error}
servers={servers}
organizations={authorizedOrganizations}
organization={organization}
addOrganization={addOrganization}
addOrganizations={creatableOrganizations}
canOpenServer={canOpenServer}
onSelectOrganization={(org) => {
setSearchParams((params) => {
const next = new URLSearchParams(params);
next.set(orgSearchParam, org.name);
return next;
});
}}
/>
)}
</RequirePermission>
)}
</RequirePermission>
);
};
@@ -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,11 @@ const meta: Meta<typeof MCPServersPageView> = {
MockImageMCPServer,
MockMemoryMCPServer,
],
organizations: [MockDefaultOrganization],
organization: MockDefaultOrganization,
addOrganization: MockDefaultOrganization,
canOpenServer: true,
onSelectOrganization: fn(),
},
parameters: {
// TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude.
@@ -73,6 +82,22 @@ 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 ${MockDefaultOrganization.display_name}`,
});
await expect(picker).toBeVisible();
await expect(picker).toHaveTextContent(
MockDefaultOrganization.display_name,
);
},
};
export const LoadError: Story = {
args: {
error: new Error("Failed to load MCP servers"),
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { getOrganizationLabel } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import {
SettingsHeader,
SettingsHeaderDescription,
@@ -19,29 +20,63 @@ 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, updateMCPServerPath } from "./organizationParam";
interface MCPServersPageViewProps {
isLoading: boolean;
error: unknown;
servers: readonly TypesGen.MCPServerConfig[];
organizations: readonly TypesGen.Organization[];
organization: TypesGen.Organization;
addOrganization?: TypesGen.Organization;
addOrganizations: readonly TypesGen.Organization[];
canOpenServer: boolean;
onSelectOrganization: (organization: TypesGen.Organization) => void;
}
const MCPServersPageView: FC<MCPServersPageViewProps> = ({
isLoading,
error,
servers,
organizations,
organization,
addOrganization,
addOrganizations,
canOpenServer,
onSelectOrganization,
}) => {
const navigate = useNavigate();
const goToAddServer = () => void navigate("/ai/settings/mcp-servers/add");
// 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,
organization,
])}`
: undefined;
const goToAddServer = () => {
if (addOrganization) {
void navigate(addMCPServerPath(addOrganization));
}
};
return (
<div>
<SettingsHeader
actions={
<Button variant="outline" onClick={goToAddServer}>
<PlusIcon />
<span>Add server</span>
</Button>
addOrganization && (
<Button
variant="outline"
onClick={goToAddServer}
aria-label={addButtonLabel}
title={addButtonLabel}
>
<PlusIcon />
<span>Add server</span>
</Button>
)
}
>
<SettingsHeaderTitle>MCP servers</SettingsHeaderTitle>
@@ -50,6 +85,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
Agents.
</SettingsHeaderDescription>
</SettingsHeader>
<OrganizationPicker
id="mcp-servers-organization"
className="mb-4"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
/>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
@@ -75,10 +117,17 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
message="No MCP servers configured"
description="Add a server to give agents access to external tools."
cta={
<Button variant="outline" onClick={goToAddServer}>
<PlusIcon />
<span>Add server</span>
</Button>
addOrganization ? (
<Button
variant="outline"
onClick={goToAddServer}
aria-label={addButtonLabel}
title={addButtonLabel}
>
<PlusIcon />
<span>Add server</span>
</Button>
) : undefined
}
/>
) : (
@@ -86,8 +135,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
<MCPServerRow
key={server.id}
server={server}
onClick={() =>
void navigate(`/ai/settings/mcp-servers/${server.id}`)
onClick={
canOpenServer
? () =>
void navigate(
updateMCPServerPath(server.id, organization),
)
: undefined
}
/>
))
@@ -1,103 +1,225 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Navigate, useNavigate, useParams } from "react-router";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { getErrorMessage, isApiError } from "#/api/errors";
import {
deleteMCPServerConfig,
mcpServerConfigs,
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";
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,
orgSearchParam,
selectOrganization,
} 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 [searchParams] = useSearchParams();
const organizationPermissionsQuery = useQuery({
...organizationsPermissions(
organizations.map((organization) => organization.id),
),
enabled: !permissions.editDeploymentConfig,
});
const manageableOrganizations = permissions.editDeploymentConfig
? organizations
: organizations.filter((organization) => {
const organizationPermissions =
organizationPermissionsQuery.data?.[organization.id];
return Boolean(
organizationPermissions?.updateMCPServerConfig ||
organizationPermissions?.deleteMCPServerConfig,
);
});
const requestedOrganizationName = searchParams.get(orgSearchParam);
const organization =
requestedOrganizationName === null
? manageableOrganizations.length > 0
? selectOrganization(manageableOrganizations, null)
: undefined
: manageableOrganizations.find(
(organization) => organization.name === requestedOrganizationName,
);
const organizationPermissions = organization
? organizationPermissionsQuery.data?.[organization.id]
: undefined;
const canUpdate =
permissions.editDeploymentConfig ||
Boolean(organizationPermissions?.updateMCPServerConfig);
const canDelete =
permissions.editDeploymentConfig ||
Boolean(organizationPermissions?.deleteMCPServerConfig);
const canManage = canUpdate || canDelete;
const queryClient = useQueryClient();
const navigate = useNavigate();
const serversQuery = useQuery({
...mcpServerConfigs(organization),
enabled: Boolean(organization),
const serverQuery = useQuery({
...mcpServerConfig(organization?.id ?? "", serverId ?? ""),
enabled: Boolean(serverId) && canManage,
});
const server = serversQuery.data?.find((item) => item.id === serverId);
const serverOrganization = server?.organization_id ?? organization;
const server = serverQuery.data;
// The backend rejects any update touching a user_oidc server without
// deployment permission, so org-level update grants cannot manage them.
const canUpdateServer =
permissions.editDeploymentConfig ||
(canUpdate && server?.auth_type !== "user_oidc");
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.
const notFound =
serverQuery.isError &&
isApiError(serverQuery.error) &&
serverQuery.error.response.status === 404;
const listPath = organization
? mcpServersPath(organization)
: "/ai/settings/mcp-servers";
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
{!serverId ? (
<Navigate to="/ai/settings/mcp-servers" replace />
) : serversQuery.isLoading ? (
<>
<title>{pageTitle("Loading...", "AI Settings")}</title>
<Loader fullscreen />
</>
) : !server ? (
<Navigate to="/ai/settings/mcp-servers" replace />
<RequirePermission
isFeatureVisible={
permissions.editDeploymentConfig ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig
}
>
{organizationPermissionsQuery.isLoadingError ? (
<ErrorAlert error={organizationPermissionsQuery.error} />
) : !permissions.editDeploymentConfig &&
!organizationPermissionsQuery.data ? (
<Loader />
) : (
<UpdateMCPServerPageView
server={server}
isSaving={updateMutation.isPending}
isDeleting={deleteMutation.isPending}
onCancel={() => void navigate("/ai/settings/mcp-servers")}
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");
} 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("/ai/settings/mcp-servers", { replace: true });
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to delete MCP server."),
);
}
}}
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.`,
),
);
},
},
);
}}
/>
<RequirePermission
isFeatureVisible={canManage && Boolean(organization)}
>
{organizationPermissionsQuery.isRefetchError && (
<div className="mb-4">
<ErrorAlert error={organizationPermissionsQuery.error} />
</div>
)}
{!serverId ? (
<Navigate to={listPath} replace />
) : serverQuery.isLoading ? (
<>
<title>{pageTitle("Loading...", "AI Settings")}</title>
<Loader fullscreen />
</>
) : serverQuery.isLoadingError && !notFound ? (
<>
<title>{pageTitle("MCP servers", "AI Settings")}</title>
<div className="mb-4">
<ErrorAlert error={serverQuery.error} />
</div>
</>
) : notFound || !server || !organization ? (
<Navigate to={listPath} replace />
) : (
<>
{serverQuery.isRefetchError && (
<div className="mb-4">
<ErrorAlert error={serverQuery.error} />
</div>
)}
<UpdateMCPServerPageView
server={server}
organizations={manageableOrganizations}
organization={organization}
listPath={listPath}
isSaving={updateMutation.isPending}
isDeleting={deleteMutation.isPending}
canSelectUserOIDC={permissions.editDeploymentConfig}
onCancel={() => void navigate(listPath)}
onUpdateServer={
canUpdateServer
? 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.",
),
);
}
}
: undefined
}
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={
canUpdateServer
? (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
}
/>
</>
)}
</RequirePermission>
)}
</RequirePermission>
);
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
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 { MockDefaultOrganization } from "#/testHelpers/entities";
import { MockCoderMCPServer } from "../testFixtures";
import UpdateMCPServerPageView from "./UpdateMCPServerPageView";
@@ -17,7 +18,11 @@ const meta: Meta<typeof UpdateMCPServerPageView> = {
component: UpdateMCPServerPageView,
args: {
server: MockCoderMCPServer,
organizations: [MockDefaultOrganization],
organization: MockDefaultOrganization,
listPath: "/ai/settings/mcp-servers",
isSaving: false,
canSelectUserOIDC: true,
isDeleting: false,
onUpdateServer,
onDeleteServer: fn(async () => undefined),
@@ -39,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 }),
@@ -63,3 +73,63 @@ export const Default: Story = {
expect(onUpdateServer.mock.calls[0]?.[1]).not.toHaveProperty("enabled");
},
};
export const UserOIDCRequiresDeploymentPermission: Story = {
args: {
canSelectUserOIDC: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
canvas.getByRole("button", { name: /authentication/i }),
);
await userEvent.click(
canvas.getByRole("combobox", { name: /authentication method/i }),
);
await expect(
body.getByRole("option", { name: "OAuth2" }),
).toBeInTheDocument();
expect(
body.queryByRole("option", { name: "User OIDC identity" }),
).not.toBeInTheDocument();
await expect(canvas.getByLabelText(/display name/i)).toBeEnabled();
},
};
export const DeleteOnly: Story = {
args: {
onUpdateServer: undefined,
onToggleEnabled: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
await expect(canvas.getByLabelText(/^slug/i)).toBeDisabled();
await expect(canvas.getByLabelText(/server url/i)).toBeDisabled();
await expect(
canvas.getByRole("button", { name: "Update server" }),
).toBeDisabled();
const serverActions = canvas.getByRole("button", {
name: "Server actions",
});
const enabledSwitch = canvas.getByRole("switch", {
name: "Server enabled",
});
await expect(enabledSwitch).toHaveAttribute("aria-disabled", "true");
await expect(enabledSwitch).toHaveAccessibleDescription(
"You do not have permission to update this server.",
);
serverActions.focus();
await userEvent.tab();
await expect(enabledSwitch).toHaveFocus();
await expect(await body.findByRole("tooltip")).toHaveTextContent(
"You do not have permission to update this server.",
);
await userEvent.click(serverActions);
await expect(
await body.findByRole("menuitem", { name: "Remove" }),
).toBeEnabled();
},
};
@@ -2,24 +2,33 @@ 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;
onUpdateServer: (
canSelectUserOIDC: boolean;
onUpdateServer?: (
serverId: string,
req: TypesGen.UpdateMCPServerConfigRequest,
) => Promise<unknown>;
onDeleteServer: (serverId: string) => Promise<void>;
onToggleEnabled: (enabled: boolean) => void;
onDeleteServer?: (serverId: string) => Promise<void>;
onToggleEnabled?: (enabled: boolean) => void;
onCancel: () => void;
}
const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
server,
organizations,
organization,
listPath,
isSaving,
isDeleting,
canSelectUserOIDC,
onUpdateServer,
onDeleteServer,
onToggleEnabled,
@@ -28,11 +37,20 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
return (
<>
<title>{pageTitle(server.display_name, "AI Settings")}</title>
<OrganizationPicker
id="mcp-update-organization"
className="mb-6"
organizations={organizations}
organization={organization}
showSingleOrganization
/>
<MCPServerForm
key={server.id}
server={server}
listPath={listPath}
isSaving={isSaving}
isDeleting={isDeleting}
canSelectUserOIDC={canSelectUserOIDC}
onUpdateServer={onUpdateServer}
onDeleteServer={onDeleteServer}
onToggleEnabled={onToggleEnabled}
@@ -17,17 +17,29 @@ import {
SECRET_PLACEHOLDER,
} from "./mcpServerFormLogic";
interface MCPServerAuthSectionProps {
interface MCPServerAuthFieldsProps {
form: FormikContextType<MCPServerFormValues>;
formId: string;
disabled: boolean;
}
interface MCPServerAuthSectionProps extends MCPServerAuthFieldsProps {
canSelectUserOIDC: boolean;
}
export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
form,
formId,
disabled,
canSelectUserOIDC,
}) => {
const authTypeOptions = AUTH_TYPE_OPTIONS.filter(
(option) =>
option.value !== "user_oidc" ||
canSelectUserOIDC ||
form.initialValues.authType === "user_oidc",
);
return (
<>
<Field
@@ -44,7 +56,7 @@ export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_OPTIONS.map((option) => (
{authTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@@ -70,7 +82,7 @@ export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
);
};
const OAuth2Fields: FC<MCPServerAuthSectionProps> = ({
const OAuth2Fields: FC<MCPServerAuthFieldsProps> = ({
form,
formId,
disabled,
@@ -144,7 +156,7 @@ const OAuth2Fields: FC<MCPServerAuthSectionProps> = ({
</div>
);
const APIKeyFields: FC<MCPServerAuthSectionProps> = ({
const APIKeyFields: FC<MCPServerAuthFieldsProps> = ({
form,
formId,
disabled,
@@ -212,7 +224,7 @@ const SecretInput: FC<{
/>
);
const CustomHeadersFields: FC<MCPServerAuthSectionProps> = ({
const CustomHeadersFields: FC<MCPServerAuthFieldsProps> = ({
form,
formId,
disabled,
@@ -15,23 +15,29 @@ import {
type MCPServerFormCreateProps = {
server?: undefined;
// 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;
canSelectUserOIDC: boolean;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
) => Promise<unknown>;
onUpdateServer?: undefined;
onDeleteServer?: undefined;
onToggleEnabled?: undefined;
onCancel: () => void;
onCancel?: () => void;
};
type MCPServerFormEditProps = {
server: TypesGen.MCPServerConfig;
listPath: string;
isSaving: boolean;
isDeleting: boolean;
canSelectUserOIDC: boolean;
onCreateServer?: undefined;
onUpdateServer: (
onUpdateServer?: (
serverId: string,
req: TypesGen.UpdateMCPServerConfigRequest,
) => Promise<unknown>;
@@ -44,8 +50,10 @@ type MCPServerFormProps = MCPServerFormCreateProps | MCPServerFormEditProps;
export const MCPServerForm: FC<MCPServerFormProps> = ({
server,
listPath,
isSaving,
isDeleting = false,
canSelectUserOIDC,
onCreateServer,
onUpdateServer,
onDeleteServer,
@@ -69,13 +77,20 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
buildUpdateMCPServerConfigRequest(values),
);
} else if (onCreateServer) {
await onCreateServer(buildCreateMCPServerConfigRequest(values));
const created = await onCreateServer(
buildCreateMCPServerConfigRequest(values),
);
if (created === true) {
form.resetForm();
}
}
},
});
const isDisabled = isSaving || isDeleting;
const canSubmit = canSubmitMCPServerForm(form.values, isDisabled);
const areFieldsDisabled =
isDisabled || (isEditing && onUpdateServer === undefined);
const canSubmit = canSubmitMCPServerForm(form.values, areFieldsDisabled);
const unsavedChanges = useUnsavedChangesPrompt(
form.dirty && !form.isSubmitting,
);
@@ -89,18 +104,22 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
server={server}
title={title}
iconUrl={form.values.iconURL}
listPath={listPath}
isEditing={isEditing}
isDisabled={isDisabled}
onRequestDelete={() => setConfirmingDelete(true)}
onRequestDelete={
onDeleteServer ? () => setConfirmingDelete(true) : undefined
}
onToggleEnabled={onToggleEnabled}
/>
<div className="flex flex-col gap-6 pt-6">
<MCPServerFormFields
form={form}
isSaving={isSaving}
isDisabled={isDisabled}
isDisabled={areFieldsDisabled}
canSubmit={canSubmit}
isEditing={isEditing}
canSelectUserOIDC={canSelectUserOIDC}
onCancel={onCancel}
showDetails={showDetails}
setShowDetails={setShowDetails}
@@ -30,7 +30,8 @@ interface MCPServerFormFieldsProps {
isDisabled: boolean;
canSubmit: boolean;
isEditing: boolean;
onCancel: () => void;
canSelectUserOIDC: boolean;
onCancel?: () => void;
showDetails: boolean;
setShowDetails: (open: boolean) => void;
showAuth: boolean;
@@ -45,6 +46,7 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
isDisabled,
canSubmit,
isEditing,
canSelectUserOIDC,
onCancel,
showDetails,
setShowDetails,
@@ -178,6 +180,7 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
form={form}
formId={formId}
disabled={isDisabled}
canSelectUserOIDC={canSelectUserOIDC}
/>
</CollapsibleSection>
@@ -198,14 +201,16 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={isDisabled}
>
Cancel
</Button>
{onCancel && (
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={isDisabled}
>
Cancel
</Button>
)}
<Button disabled={!canSubmit} type="submit">
<Spinner loading={isSaving} />
{isEditing ? "Update server" : "Add server"}
@@ -1,5 +1,5 @@
import { ArrowLeftIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
import type { FC } from "react";
import { type FC, useId } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
@@ -20,9 +20,9 @@ import {
import { cn } from "#/utils/cn";
import { MCPServerIcon } from "./MCPServerIcon";
const MCPServerFormBackLink: FC = () => {
const MCPServerFormBackLink: FC<{ to: string }> = ({ to }) => {
return (
<Link to="/ai/settings/mcp-servers" className="-ml-3">
<Link to={to} className="-ml-3">
<Button variant="subtle" type="button">
<ArrowLeftIcon />
<span>Back to MCP servers</span>
@@ -35,9 +35,10 @@ interface MCPServerFormHeaderProps {
server?: TypesGen.MCPServerConfig;
title: string;
iconUrl: string;
listPath?: string;
isEditing: boolean;
isDisabled: boolean;
onRequestDelete: () => void;
onRequestDelete?: () => void;
onToggleEnabled?: (enabled: boolean) => void;
}
@@ -45,16 +46,20 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
server,
title,
iconUrl,
listPath,
isEditing,
isDisabled,
onRequestDelete,
onToggleEnabled,
}) => {
const disabledReasonId = useId();
const lacksUpdatePermission = !onToggleEnabled;
return (
<>
<div className="flex items-center justify-between">
<MCPServerFormBackLink />
{isEditing && server && (
{listPath && <MCPServerFormBackLink to={listPath} />}
{isEditing && server && onRequestDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -105,18 +110,34 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
<span className="inline-flex">
<Switch
checked={server.enabled}
onCheckedChange={(checked) => onToggleEnabled?.(checked)}
onCheckedChange={(checked) => {
if (onToggleEnabled) {
onToggleEnabled(checked);
}
}}
disabled={isDisabled}
aria-disabled={lacksUpdatePermission}
aria-describedby={
lacksUpdatePermission ? disabledReasonId : undefined
}
aria-label="Server enabled"
className="aria-disabled:cursor-not-allowed aria-disabled:data-[state=checked]:bg-surface-tertiary aria-disabled:data-[state=unchecked]:bg-surface-tertiary"
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{server.enabled
? "Disable this server. It will be hidden from agents."
: "Enable this server. It will be visible to agents."}
{lacksUpdatePermission
? "You do not have permission to update this server."
: server.enabled
? "Disable this server. It will be hidden from agents."
: "Enable this server. It will be visible to agents."}
</TooltipContent>
</Tooltip>
{lacksUpdatePermission && (
<span id={disabledReasonId} className="sr-only">
You do not have permission to update this server.
</span>
)}
<span className="text-sm">Enable</span>
</div>
)}
@@ -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<MCPServerRowProps> = ({ server, onClick }) => {
const clickableProps = useClickableTableRow({ onClick });
const clickableProps = useClickableTableRow({
onClick: () => onClick?.(),
});
const enabled = server.enabled;
return (
<TableRow {...clickableProps}>
<TableRow {...(onClick ? clickableProps : {})}>
<TableCell className="min-w-0 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<MCPServerIcon
@@ -46,7 +48,9 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
<Badge variant="default">{enabled ? "Enabled" : "Disabled"}</Badge>
</TableCell>
<TableCell className="w-12">
<ChevronRightIcon className="size-5 text-content-primary" />
{onClick && (
<ChevronRightIcon className="size-5 text-content-primary" />
)}
</TableCell>
</TableRow>
);
@@ -0,0 +1,64 @@
import type { FC } from "react";
import type { Organization } from "#/api/typesGenerated";
import { Label } from "#/components/Label/Label";
import {
getOrganizationLabel,
OrganizationAutocomplete,
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { cn } from "#/utils/cn";
interface OrganizationPickerProps {
id: string;
organizations: readonly Organization[];
organization: Organization;
onChange?: (organization: Organization) => void;
className?: string;
disabled?: boolean;
showSingleOrganization?: boolean;
}
export const OrganizationPicker: FC<OrganizationPickerProps> = ({
id,
organizations,
organization,
onChange,
className,
disabled,
showSingleOrganization = false,
}) => {
const hasSingleSelectedOrganization =
organizations.length <= 1 &&
organizations.some((option) => option.id === organization.id);
if (hasSingleSelectedOrganization && !showSingleOrganization) {
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 (
<div className={cn("flex w-72 flex-col gap-2", className)}>
<Label htmlFor={id}>Organization</Label>
<OrganizationAutocomplete
id={id}
ariaLabel={`Organization ${getOrganizationLabel(organization, labelOrganizations)}`}
value={organization}
onChange={(org) => {
if (org) {
onChange?.(org);
}
}}
options={organizations}
labelOrganizations={labelOrganizations}
required
disabled={disabled || !onChange || hasSingleSelectedOrganization}
/>
</div>
);
};
@@ -0,0 +1,29 @@
import type { Organization } from "#/api/typesGenerated";
export const orgSearchParam = "org";
export const selectOrganization = (
organizations: readonly Organization[],
name: string | null,
): 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) =>
`/ai/settings/mcp-servers?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
export const addMCPServerPath = (organization: Organization) =>
`/ai/settings/mcp-servers/add?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
export const updateMCPServerPath = (
serverId: string,
organization: Organization,
) =>
`/ai/settings/mcp-servers/${serverId}?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
+13 -1
View File
@@ -465,7 +465,7 @@ const AISettingsUpdateMCPServerPage = lazy(
),
);
const AISettingsIndexRedirect = () => {
export const AISettingsIndexRedirect = () => {
const { permissions } = useAuthenticated();
if (permissions.viewAnyAIProvider) {
@@ -480,6 +480,18 @@ const AISettingsIndexRedirect = () => {
return <Navigate to="/ai/settings/models" replace />;
}
if (
permissions.viewAnyMCPServerConfigs ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig
) {
return <Navigate to="/ai/settings/mcp-servers" replace />;
}
if (permissions.createAnyMCPServerConfig) {
return <Navigate to="/ai/settings/mcp-servers/add" replace />;
}
return <Navigate to="/ai/settings/providers" replace />;
};
+16
View File
@@ -3366,6 +3366,10 @@ export const MockPermissions: Permissions = {
editAnyGroups: true,
editAnySettings: true,
viewAnyIdpSyncSettings: true,
viewAnyMCPServerConfigs: true,
createAnyMCPServerConfig: true,
updateAnyMCPServerConfig: true,
deleteAnyMCPServerConfig: true,
viewAnyMembers: true,
viewAnyAIBridgeInterception: true,
viewAnyAIProvider: true,
@@ -3403,6 +3407,10 @@ export const MockNoPermissions: Permissions = {
editAnyGroups: false,
editAnySettings: false,
viewAnyIdpSyncSettings: false,
viewAnyMCPServerConfigs: false,
createAnyMCPServerConfig: false,
updateAnyMCPServerConfig: false,
deleteAnyMCPServerConfig: false,
viewAnyMembers: false,
viewAnyAIBridgeInterception: false,
viewAnyAIProvider: false,
@@ -3430,6 +3438,10 @@ export const MockOrganizationPermissions: OrganizationPermissions = {
viewProvisionerJobs: true,
viewIdpSyncSettings: true,
editIdpSyncSettings: true,
viewMCPServerConfigs: true,
createMCPServerConfig: true,
updateMCPServerConfig: true,
deleteMCPServerConfig: true,
};
export const MockNoOrganizationPermissions: OrganizationPermissions = {
@@ -3448,6 +3460,10 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = {
viewProvisionerJobs: false,
viewIdpSyncSettings: false,
editIdpSyncSettings: false,
viewMCPServerConfigs: false,
createMCPServerConfig: false,
updateMCPServerConfig: false,
deleteMCPServerConfig: false,
};
export const MockDeploymentConfig: DeploymentConfig = {