diff --git a/coderd/audit.go b/coderd/audit.go index 4188478cf52..84fa32d29c5 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -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. diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 94d7002ea6a..43abd96dc6f 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -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) { diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index b4c6ee7b0aa..d7e8969e4ec 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -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 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)) + }) } diff --git a/site/permissions.json b/site/permissions.json index 71b91a51b12..5e401f82386 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -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" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c8a54b696bf..44a854c43d6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3921,6 +3921,16 @@ class ExperimentalApiMethods { return response.data; }; + getMCPServerConfig = async ( + organization: string, + id: string, + ): Promise => { + const response = await this.axios.get( + mcpServerConfigPath(organization, 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..66decae1523 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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 => + API.experimental.getMCPServerConfig(organization, id), +}); + const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { await queryClient.invalidateQueries({ queryKey: mcpServersKey }); }; diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx index d41f6b443cc..307b670df0a 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx @@ -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, diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 7c70a547562..ff48d28345b 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -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 = ({ value, onChange, options, + labelOrganizations, id, + ariaLabel, required, + disabled, }) => { const [open, setOpen] = useState(false); + const labelContext = labelOrganizations ?? options; return ( + ); +}; + +const withRefetchServerDetailProbe: Decorator = (Story) => ( + <> + + + +); + +const RefetchPermissionsProbe: FC = () => { + const queryClient = useQueryClient(); + const { organizations } = useDashboard(); + return ( + + ); +}; + +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"}
; +}; + +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"}

; +}; + const meta = { title: "pages/AISettingsPage/MCPServersPage/MCPServersPage", component: MCPServersPage, - decorators: [withAuthProvider, withDashboardProvider], + decorators: [withToaster, withAuthProvider, withDashboardProvider], parameters: { layout: "fullscreen", user: MockUserOwner, @@ -47,24 +199,511 @@ export const ListUsesDefaultOrganization: Story = { ); }); await expect(canvas.getByText("Coder")).toBeVisible(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).not.toBeInTheDocument(); }, }; -export const AddUsesDefaultOrganization: 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: () => { + 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, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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 DeleteOnlyOrgAdminCanOpenMCPServer: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: true, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + 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]: { delete: true }, + [MockOrganization2.id]: { delete: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + async (organization) => + organization === MockOrganization2.id + ? [MockOrganization2MCPServer] + : [MockCoderMCPServer], + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await canvas.findByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ); + await userEvent.click( + await body.findByRole("option", { + name: MockOrganization2.display_name, + }), + ); + await userEvent.click( + await canvas.findByRole("button", { name: /Org2 Search/ }), + ); + await expect( + await canvas.findByRole("heading", { + name: `detail-org:${MockOrganization2.name}`, + }), + ).toBeVisible(); + }, +}; + +export const UpdateOnlyOrgAdminUsesAuthorizedOrganization: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: false, + updateAnyMCPServerConfig: true, + deleteAnyMCPServerConfig: false, + }, + 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]: { 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, + ); + }, +}; + +export const ListCanAddToCreateOnlyOrganization: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: true, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: [ + { path: "/ai/settings/mcp-servers", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/add", + element: , + }, + ], + }), + }, + 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(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).not.toBeInTheDocument(); + const addButton = canvas.getByRole("button", { + name: `Add server to ${MockOrganization2.display_name}`, + }); + await expect(addButton).toHaveAttribute( + "title", + `Add server to ${MockOrganization2.display_name}`, + ); + await userEvent.click(addButton); + await expect( + await canvas.findByRole("heading", { + name: `add-org:${MockOrganization2.name}`, + }), + ).toBeVisible(); + }, +}; + +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 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: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + 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({ + [MockOrganization2.id]: { create: true }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const organization = await canvas.findByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }); + await expect(organization).toBeVisible(); + await expect(organization).toBeDisabled(); + await expect(canvas.getByLabelText(/display name/i)).toBeVisible(); + }, +}; + +export const ListSwitchesOrganization: Story = { + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + 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 ${MockDefaultOrganization.display_name}`, + }), + ); + 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, + ); + }, +}; + +export const ListDisambiguatesCollidingOrganizationNames: Story = { + parameters: { + organizations: [ + { + ...MockDefaultOrganization, + name: "org-a", + display_name: "Dev", + }, + { + ...MockOrganization2, + name: "org-b", + display_name: "Dev", + }, + ], + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/mcp-servers", + searchParams: { [orgSearchParam]: "org-a" }, + }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { + name: /Organization Dev \(org-a\)/, + }), + ).toBeVisible(); + }, +}; + +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); + const body = within(canvasElement.ownerDocument.body); + await expect(await canvas.findByLabelText(/display name/i)).toBeVisible(); + await expect( + canvas.getByRole("button", { name: "Add server" }), + ).toBeVisible(); + 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(); + }, +}; + +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( - MockCoderMCPServer, + 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 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), @@ -73,37 +712,931 @@ export const AddUsesDefaultOrganization: Story = { await userEvent.click(canvas.getByRole("button", { name: "Add server" })); await waitFor(() => { expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( - MockDefaultOrganization.id, - expect.objectContaining({ - display_name: "GitHub", - slug: "github", - url: "https://api.githubcopilot.com/mcp/", - }), + MockOrganization2.id, + expect.objectContaining({ display_name: "GitHub" }), ); }); + await expect( + await body.findByText( + `MCP server "${MockOrganization2MCPServer.display_name}" added.`, + ), + ).toBeInTheDocument(); + 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( + canvas.getByRole("button", { name: "Add server" }), + ).toBeDisabled(); }, }; -export const UpdateLoadsDefaultOrganization: Story = { - render: () => , +export const AddDeniedRequestedOrganizationDoesNotFallback: Story = { + render: () => , + decorators: [withRefetchPermissionsProbe], parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: true, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + organizations: [MockDefaultOrganization, MockOrganization2], reactRouter: reactRouterParameters({ - location: { path: "/ai/settings/mcp-servers/mcp-coder" }, - routing: { path: "/ai/settings/mcp-servers/:serverId" }, + location: { + path: "/ai/settings/mcp-servers/add", + searchParams: { [orgSearchParam]: MockDefaultOrganization.name }, + }, + routing: { path: "/ai/settings/mcp-servers/add" }, }), }, beforeEach: () => { - spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ - MockCoderMCPServer, - ]); + const checkAuthorization = mockOrganizationPermissions({ + [MockDefaultOrganization.id]: {}, + [MockOrganization2.id]: { create: true }, + }); + checkAuthorization.mockImplementationOnce(async ({ checks }) => + organizationPermissionsResponse( + { + [MockDefaultOrganization.id]: { view: true, create: true }, + [MockOrganization2.id]: { create: true }, + }, + checks, + ), + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => { - expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( - MockDefaultOrganization.id, - ); + 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(); + }, +}; + +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 }, }); - await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder"); + 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: { + 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 }, + }); + spyOn(API.experimental, "createMCPServerConfig").mockRejectedValue( + mockApiError({ message: "Invalid client credentials." }), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.type( + await canvas.findByLabelText(/display name/i), + "GitHub", + ); + await userEvent.type( + canvas.getByLabelText(/server url/i), + "https://api.githubcopilot.com/mcp/", + ); + await userEvent.click( + canvas.getByRole("button", { name: /authentication/i }), + ); + await userEvent.click( + canvas.getByRole("combobox", { name: /authentication method/i }), + ); + await userEvent.click(body.getByRole("option", { name: "OAuth2" })); + await userEvent.type(canvas.getByLabelText(/client id/i), "client-id"); + await userEvent.type(canvas.getByLabelText(/client secret/i), "secret"); + await userEvent.click(canvas.getByRole("button", { name: "Add server" })); + await expect( + await body.findByText("Invalid client credentials."), + ).toBeInTheDocument(); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue("GitHub"); + await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("github"); + await expect(canvas.getByLabelText(/server url/i)).toHaveValue( + "https://api.githubcopilot.com/mcp/", + ); + await expect(canvas.getByLabelText(/client id/i)).toHaveValue("client-id"); + await expect(canvas.getByLabelText(/client secret/i)).toHaveValue("secret"); + }, +}; + +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: { + 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 }), + ).toBeInTheDocument(); + 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 canvas.findByRole("alert")).toHaveTextContent( + "You cannot add servers to this organization", + ); + 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(canvas.queryByRole("alert")).not.toBeInTheDocument(); + }, +}; + +export const AddUsesDefaultOrganization: Story = { + render: () => , + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + }, + 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: "Add server" })); + await waitFor(() => { + expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + expect.objectContaining({ + display_name: "GitHub", + slug: "github", + url: "https://api.githubcopilot.com/mcp/", + }), + ); + }); + }, +}; + +export const AddToSelectedOrganization: Story = { + render: () => , + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/mcp-servers/add", + searchParams: { [orgSearchParam]: MockOrganization2.name }, + }, + routing: [ + { path: "/ai/settings/mcp-servers/add", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/:serverId", + element: , + }, + ], + }), + }, + beforeEach: () => { + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockOrganization2MCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }), + ).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" }), + ); + }); + await expect( + await canvas.findByText(`detail-org:${MockOrganization2.name}`), + ).toBeVisible(); + }, +}; + +export const AddDisablesOrganizationWhileSaving: 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").mockImplementation( + () => new Promise(() => {}), + ); + }, + 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: "Add server" })); + await waitFor(() => { + expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeDisabled(); + }); + }, +}; + +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 ${MockDefaultOrganization.display_name}`, + }), + ); + 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 expect( + canvas.getByRole("link", { name: /back to mcp servers/i }), + ).toHaveAttribute( + "href", + `/ai/settings/mcp-servers?org=${MockOrganization2.name}`, + ); + 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 UpdateShowsDetailLoadError: Story = { + render: () => , + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( + new Error("Failed to load MCP server."), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText("Failed to load MCP server."), + ).toBeVisible(); + expect(canvas.queryByLabelText(/display name/i)).not.toBeInTheDocument(); + }, +}; + +export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + updateAnyMCPServerConfig: true, + deleteAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { update: 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" }), + ).toBeEnabled(); + await expect( + canvas.getByRole("switch", { name: "Server enabled" }), + ).toBeEnabled(); + 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(); + expect( + canvas.queryByRole("button", { name: "Server actions" }), + ).not.toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /delete server/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const UpdateDeniedRequestedOrganizationDoesNotFallback: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + updateAnyMCPServerConfig: true, + deleteAnyMCPServerConfig: false, + }, + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: `/ai/settings/mcp-servers/${MockCoderMCPServer.id}`, + searchParams: { [orgSearchParam]: MockDefaultOrganization.name }, + }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: {}, + [MockOrganization2.id]: { update: true }, + }); + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( + MockOrganization2MCPServer, + ); + }, + 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"), + ).toBeInTheDocument(); + expect(canvas.queryByLabelText(/display name/i)).not.toBeInTheDocument(); + expect(API.experimental.getMCPServerConfig).not.toHaveBeenCalled(); + }, +}; + +export const UserOIDCOrgAdminCannotUpdate: 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, + delete: true, + }, + }); + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue({ + ...MockCoderMCPServer, + auth_type: "user_oidc", + }); + }, + 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(); + 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.", + ); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + await userEvent.click( + canvas.getByRole("button", { name: /authentication/i }), + ); + await expect( + canvas.getByLabelText(/authentication method/i), + ).toBeDisabled(); + await expect( + canvas.getByLabelText(/authentication method/i), + ).toHaveTextContent("User OIDC identity"); + await userEvent.click( + canvas.getByRole("button", { name: "Server actions" }), + ); + await expect( + await body.findByRole("menuitem", { name: "Remove" }), + ).toBeEnabled(); + }, +}; + +export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = { + render: () => , + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: true, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { delete: true }, + }); + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + spyOn(API.experimental, "deleteMCPServerConfig").mockResolvedValue(); + }, + 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(); + 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.", + ); + await expect(canvas.getByLabelText(/^slug/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/server url/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/^transport/i)).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: /details/i })); + await expect(canvas.getByLabelText(/^description/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/^icon/i)).toBeDisabled(); + await userEvent.click( + canvas.getByRole("button", { name: /authentication/i }), + ); + await expect( + canvas.getByLabelText(/authentication method/i), + ).toBeDisabled(); + await expect(canvas.getByLabelText(/client id/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/client secret/i)).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: /behavior/i })); + await expect(canvas.getByLabelText(/^availability/i)).toBeDisabled(); + await expect( + canvas.getByRole("switch", { name: "Model intent" }), + ).toBeDisabled(); + await expect(canvas.getByLabelText(/tool allow list/i)).toBeDisabled(); + await expect(canvas.getByLabelText(/tool deny list/i)).toBeDisabled(); + await userEvent.click( + canvas.getByRole("button", { name: "Server actions" }), + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "Remove" }), + ); + await userEvent.click( + await body.findByRole("button", { name: "Delete MCP server" }), + ); + await waitFor(() => { + expect(API.experimental.deleteMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + ); + }); + }, +}; + +export const UpdateLoadsServerById: Story = { + render: () => , + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + ); + }); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder"); + }, +}; + +export const RowClickCarriesSelectedOrganization: Story = { + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: [ + { path: "/ai/settings/mcp-servers", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/:serverId", + element: , + }, + ], + }), + }, + beforeEach: () => { + 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 ${MockDefaultOrganization.display_name}`, + }), + ); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { + name: MockOrganization2.display_name, + }), + ); + await userEvent.click(await canvas.findByText("Org2 Search")); + await expect( + await canvas.findByText(`detail-org:${MockOrganization2.name}`), + ).toBeVisible(); + }, +}; + +const mockNotFoundError = (() => { + const error = mockApiError({ message: "Resource not found" }); + return { ...error, response: { ...error.response, status: 404 } }; +})(); + +export const UpdateWrongOrganizationRedirectsToSelectedList: Story = { + render: () => , + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { + path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, + searchParams: { [orgSearchParam]: MockDefaultOrganization.name }, + }, + routing: [ + { path: "/ai/settings/mcp-servers/:serverId", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers", + element: , + }, + ], + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( + mockNotFoundError, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText(`list-org:${MockDefaultOrganization.name}`), + ).toBeVisible(); + expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockOrganization2MCPServer.id, + ); + }, +}; + +export const UpdateCachedNotFoundRedirects: Story = { + render: () => , + decorators: [withRefetchServerDetailProbe], + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + queries: [ + { + key: mockOrganization2MCPServerQueryKey, + data: MockOrganization2MCPServer, + }, + ], + reactRouter: reactRouterParameters({ + location: { + path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, + searchParams: { [orgSearchParam]: MockOrganization2.name }, + }, + routing: [ + { path: "/ai/settings/mcp-servers/:serverId", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers", + element: , + }, + ], + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( + mockNotFoundError, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue( + MockOrganization2MCPServer.display_name, + ); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch server detail" }), + ); + await expect( + await canvas.findByText(`list-org:${MockOrganization2.name}`), + ).toBeVisible(); + }, +}; + +export const UpdateRefetchErrorKeepsCachedForm: Story = { + render: () => , + decorators: [withRefetchServerDetailProbe], + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + queries: [ + { + key: mockOrganization2MCPServerQueryKey, + data: MockOrganization2MCPServer, + }, + ], + reactRouter: reactRouterParameters({ + location: { + path: `/ai/settings/mcp-servers/${MockOrganization2MCPServer.id}`, + searchParams: { [orgSearchParam]: MockOrganization2.name }, + }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue( + mockApiError({ message: "failed to refresh MCP server" }), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const displayName = canvas.getByLabelText(/display name/i); + await userEvent.clear(displayName); + await userEvent.type(displayName, "Edited name"); + await userEvent.click( + canvas.getByRole("button", { name: "Refetch server detail" }), + ); + const alert = await canvas.findByRole("alert"); + await expect( + within(alert).getByRole("heading", { + name: /failed to refresh mcp server/i, + }), + ).toBeVisible(); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue( + "Edited name", + ); }, }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 75c43196d9b..b7fb6fe0f58 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -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 ( - + Codestin Search App - + {organizationPermissionsQuery.isLoadingError ? ( + + ) : !permissions.editDeploymentConfig && + !organizationPermissionsQuery.data ? ( + + ) : ( + + {organizationPermissionsQuery.isRefetchError && ( +
+ +
+ )} + {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 f7bdda942d2..cb37b7cfa2b 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,11 @@ const meta: Meta = { 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"), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index e64cea7302b..a81e5217792 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -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 = ({ 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 (
- - Add server - + addOrganization && ( + + ) } > MCP servers @@ -50,6 +85,13 @@ const MCPServersPageView: FC = ({ Agents. + {Boolean(error) && (
@@ -75,10 +117,17 @@ const MCPServersPageView: FC = ({ message="No MCP servers configured" description="Add a server to give agents access to external tools." cta={ - + addOrganization ? ( + + ) : undefined } /> ) : ( @@ -86,8 +135,13 @@ const MCPServersPageView: FC = ({ - void navigate(`/ai/settings/mcp-servers/${server.id}`) + onClick={ + canOpenServer + ? () => + 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 6289fbcc821..e26bd6dc37c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -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 ( - - {!serverId ? ( - - ) : serversQuery.isLoading ? ( - <> - Codestin Search App - - - ) : !server ? ( - + + {organizationPermissionsQuery.isLoadingError ? ( + + ) : !permissions.editDeploymentConfig && + !organizationPermissionsQuery.data ? ( + ) : ( - 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.`, - ), - ); - }, - }, - ); - }} - /> + + {organizationPermissionsQuery.isRefetchError && ( +
+ +
+ )} + {!serverId ? ( + + ) : serverQuery.isLoading ? ( + <> + Codestin Search App + + + ) : serverQuery.isLoadingError && !notFound ? ( + <> + Codestin Search App +
+ +
+ + ) : notFound || !server || !organization ? ( + + ) : ( + <> + {serverQuery.isRefetchError && ( +
+ +
+ )} + 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 + } + /> + + )} +
)}
); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx index 5ad2d5d7d3e..be3535f06a6 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -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 = { 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(); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index 83373b6395b..dda7efcdc0d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -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; - onDeleteServer: (serverId: string) => Promise; - onToggleEnabled: (enabled: boolean) => void; + onDeleteServer?: (serverId: string) => Promise; + onToggleEnabled?: (enabled: boolean) => void; onCancel: () => void; } const UpdateMCPServerPageView: FC = ({ server, + organizations, + organization, + listPath, isSaving, isDeleting, + canSelectUserOIDC, onUpdateServer, onDeleteServer, onToggleEnabled, @@ -28,11 +37,20 @@ const UpdateMCPServerPageView: FC = ({ return ( <> Codestin Search App + ; formId: string; disabled: boolean; } +interface MCPServerAuthSectionProps extends MCPServerAuthFieldsProps { + canSelectUserOIDC: boolean; +} + export const MCPServerAuthSection: FC = ({ form, formId, disabled, + canSelectUserOIDC, }) => { + const authTypeOptions = AUTH_TYPE_OPTIONS.filter( + (option) => + option.value !== "user_oidc" || + canSelectUserOIDC || + form.initialValues.authType === "user_oidc", + ); + return ( <> = ({ - {AUTH_TYPE_OPTIONS.map((option) => ( + {authTypeOptions.map((option) => ( {option.label} @@ -70,7 +82,7 @@ export const MCPServerAuthSection: FC = ({ ); }; -const OAuth2Fields: FC = ({ +const OAuth2Fields: FC = ({ form, formId, disabled, @@ -144,7 +156,7 @@ const OAuth2Fields: FC = ({
); -const APIKeyFields: FC = ({ +const APIKeyFields: FC = ({ form, formId, disabled, @@ -212,7 +224,7 @@ const SecretInput: FC<{ /> ); -const CustomHeadersFields: FC = ({ +const CustomHeadersFields: FC = ({ form, formId, disabled, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index 16754e2fdf9..f7066458779 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -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; 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; @@ -44,8 +50,10 @@ type MCPServerFormProps = MCPServerFormCreateProps | MCPServerFormEditProps; export const MCPServerForm: FC = ({ server, + listPath, isSaving, isDeleting = false, + canSelectUserOIDC, onCreateServer, onUpdateServer, onDeleteServer, @@ -69,13 +77,20 @@ export const MCPServerForm: FC = ({ 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 = ({ server={server} title={title} iconUrl={form.values.iconURL} + listPath={listPath} isEditing={isEditing} isDisabled={isDisabled} - onRequestDelete={() => setConfirmingDelete(true)} + onRequestDelete={ + onDeleteServer ? () => setConfirmingDelete(true) : undefined + } onToggleEnabled={onToggleEnabled} />
void; + canSelectUserOIDC: boolean; + onCancel?: () => void; showDetails: boolean; setShowDetails: (open: boolean) => void; showAuth: boolean; @@ -45,6 +46,7 @@ export const MCPServerFormFields: FC = ({ isDisabled, canSubmit, isEditing, + canSelectUserOIDC, onCancel, showDetails, setShowDetails, @@ -178,6 +180,7 @@ export const MCPServerFormFields: FC = ({ form={form} formId={formId} disabled={isDisabled} + canSelectUserOIDC={canSelectUserOIDC} /> @@ -198,14 +201,16 @@ export const MCPServerFormFields: FC = ({
- + {onCancel && ( + + )}
)} 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/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx new file mode 100644 index 00000000000..08444583a68 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -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 = ({ + 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 ( +
+ + { + if (org) { + onChange?.(org); + } + }} + options={organizations} + labelOrganizations={labelOrganizations} + required + disabled={disabled || !onChange || hasSingleSelectedOrganization} + /> +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts new file mode 100644 index 00000000000..cd637d20df6 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/organizationParam.ts @@ -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)}`; diff --git a/site/src/router.tsx b/site/src/router.tsx index 6be468605ea..d4e7bb67096 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,18 @@ const AISettingsIndexRedirect = () => { return ; } + if ( + permissions.viewAnyMCPServerConfigs || + permissions.updateAnyMCPServerConfig || + permissions.deleteAnyMCPServerConfig + ) { + return ; + } + + if (permissions.createAnyMCPServerConfig) { + return ; + } + return ; }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bdcd17f4dc2..aabb300437c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = {