mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
feat: add organization picker to MCP server settings pages (#28150)
Adds an organization picker to the MCP server settings pages so
multi-org deployments can manage every organization's MCP servers
through the UI, not just the default organization's. Closes CODAGT-714
(scoped to MCP servers only; model configs stay deployment-wide until
CODAGT-709 lands).
## Stack Context
Top of the CODAGT-711/717/712/806 stack: MCP server configs are
org-scoped with RBAC, audit logging, per-config ACLs, and owner-scoped
user tokens. This layer makes the admin UI org-aware, resolving the
"default org only" limitation documented in the bottom PR.
## Summary
- The MCP servers list and add pages resolve the working organization
from an `org` search param (falling back to the default org), and render
an organization picker built on the existing `OrganizationAutocomplete`.
The picker only appears when more than one organization is visible, so
single-org deployments see no change.
- Selecting an organization rewrites the search param, refetches the
list for that org, and threads through to the add page so creation posts
to the selected organization.
- The update page now loads the server by ID (new organization-scoped
`GET /api/experimental/organizations/{organization}/mcp-servers/{id}`
frontend binding) instead of listing the default org and searching
client-side, so servers from any organization can be edited. Post-save
navigation returns to the owning organization's list via the server's
`organization_id`.
- Removes the now-unused `getDefaultOrganizationId` helper.
- Review hardening: organization display labels are disambiguated with
the unique org name when effective display names collide (picker
options, trigger text, and accessible name). Access is no longer
restricted to deployment admins: the pages, sidebar item, and nav entry
accept org-scoped `mcp_server_config` permissions, the picker lists only
organizations the user can view, content and affordances (add, edit,
delete) are gated on the selected organization's permissions, and
deployment admins keep full access everywhere.
## Testing
Storybook interaction stories cover: picker hidden with one org,
switching orgs refetches and swaps list contents, add posting to the
URL-selected org, add posting after an in-form org switch (asserting
typed form state survives the switch), and update loading by ID.
Red-green verified by breaking the search-param resolution and observing
the new stories fail.
> Mux acted on Mike's behalf for this PR.
This commit is contained in:
+18
-5
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,7 +23,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -619,16 +619,29 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
// not username-scoped in the URL like workspaces or tasks.
|
||||
return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID)
|
||||
case database.ResourceTypeMCPServerConfig:
|
||||
// Mutation-only administrators may lack read access, so fetch
|
||||
// with system access; the explicit checks below authorize.
|
||||
//nolint:gocritic // The requester is authorized against the fetched config right below.
|
||||
config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), alog.AuditLog.ResourceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
actor, ok := dbauthz.ActorFromContext(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// The MCP settings page admits only deployment-config managers,
|
||||
// so emit the link only for callers the page will accept.
|
||||
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
// The MCP settings page admits anyone who can manage the
|
||||
// config, so emit the link only for callers it will accept.
|
||||
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, config.RBACObject()); err != nil {
|
||||
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionDelete, config.RBACObject()); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
organization, err := api.Database.GetOrganizationByID(ctx, alog.AuditLog.OrganizationID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID)
|
||||
return fmt.Sprintf("/ai/settings/mcp-servers/%s?org=%s", alog.AuditLog.ResourceID, url.QueryEscape(organization.Name))
|
||||
case database.ResourceTypeUserSecret:
|
||||
// TODO(PLAT-102): point at the user secrets management page once
|
||||
// it ships. Until then, the audit row links nowhere.
|
||||
|
||||
+11
-7
@@ -152,16 +152,20 @@ func TestAuditLogs(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
auditor, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleAuditor())
|
||||
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID))
|
||||
organization := dbgen.Organization(t, db, database.Organization{
|
||||
Name: "mcp-audit-link-org",
|
||||
})
|
||||
require.NotEqual(t, user.OrganizationID, organization.ID)
|
||||
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, organization.ID, rbac.ScopedRoleOrgAdmin(organization.ID))
|
||||
|
||||
config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OrganizationID: organization.ID,
|
||||
})
|
||||
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
|
||||
Action: codersdk.AuditActionCreate,
|
||||
ResourceType: codersdk.ResourceTypeMCPServerConfig,
|
||||
ResourceID: config.ID,
|
||||
OrganizationID: user.OrganizationID,
|
||||
OrganizationID: organization.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -174,8 +178,8 @@ func TestAuditLogs(t *testing.T) {
|
||||
require.Len(t, auditorLogs.AuditLogs, 1)
|
||||
require.Empty(t, auditorLogs.AuditLogs[0].ResourceLink)
|
||||
|
||||
// Organization admins hold MCP permissions but not the
|
||||
// deployment-config access the settings page requires.
|
||||
// Organization admins hold MCP management permissions, and the
|
||||
// settings page now admits them.
|
||||
orgAdminLogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Limit: 1,
|
||||
@@ -183,7 +187,7 @@ func TestAuditLogs(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, orgAdminLogs.AuditLogs, 1)
|
||||
require.Empty(t, orgAdminLogs.AuditLogs[0].ResourceLink)
|
||||
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), orgAdminLogs.AuditLogs[0].ResourceLink)
|
||||
|
||||
ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
@@ -192,7 +196,7 @@ func TestAuditLogs(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ownerLogs.AuditLogs, 1)
|
||||
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
|
||||
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
|
||||
})
|
||||
|
||||
t.Run("Organization", func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,11 @@ servers are injected into or offered to users during chat depending on the
|
||||
availability policy.
|
||||
|
||||
This is an admin-only feature accessible at **AI Settings** > **Coder Agents** > **MCP servers**
|
||||
(`/ai/settings/mcp-servers`). The settings page currently manages the default
|
||||
organization's servers; other organizations are managed through the API.
|
||||
(`/ai/settings/mcp-servers`). In multi-organization deployments, use the
|
||||
organization picker to choose which organization's servers to manage. The
|
||||
server list shows the picker when you can access more than one organization's
|
||||
servers. The add and update views always show the target organization, as a
|
||||
read-only field when only one organization is available.
|
||||
|
||||
## Add an MCP server
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3921,6 +3921,16 @@ class ExperimentalApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getMCPServerConfig = async (
|
||||
organization: string,
|
||||
id: string,
|
||||
): Promise<TypesGen.MCPServerConfig> => {
|
||||
const response = await this.axios.get<TypesGen.MCPServerConfig>(
|
||||
mcpServerConfigPath(organization, id),
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createMCPServerConfig = async (
|
||||
organization: string,
|
||||
req: TypesGen.CreateMCPServerConfigRequest,
|
||||
|
||||
@@ -2336,6 +2336,15 @@ export const mcpServerConfigs = (organization: string) => ({
|
||||
API.experimental.getMCPServerConfigs(organization),
|
||||
});
|
||||
|
||||
export const mcpServerConfigKey = (organization: string, id: string) =>
|
||||
[...mcpServerConfigsKey(organization), "detail", id] as const;
|
||||
|
||||
export const mcpServerConfig = (organization: string, id: string) => ({
|
||||
queryKey: mcpServerConfigKey(organization, id),
|
||||
queryFn: (): Promise<TypesGen.MCPServerConfig> =>
|
||||
API.experimental.getMCPServerConfig(organization, id),
|
||||
});
|
||||
|
||||
const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => {
|
||||
await queryClient.invalidateQueries({ queryKey: mcpServersKey });
|
||||
};
|
||||
|
||||
@@ -34,6 +34,92 @@ export const ManyOrgs: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const collidingDisplayNameOrganizations = [
|
||||
{ ...MockOrganization, name: "org-a", display_name: "Dev" },
|
||||
{ ...MockOrganization2, name: "org-b", display_name: "Dev" },
|
||||
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
|
||||
];
|
||||
|
||||
export const CollidingDisplayNames: Story = {
|
||||
args: {
|
||||
value: collidingDisplayNameOrganizations[0],
|
||||
options: collidingDisplayNameOrganizations,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /Dev \(org-a\)/ }),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Dev (org-a)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Dev (org-b)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Collides with an option but is not itself selectable, mirroring a
|
||||
// deep link to a list-only organization.
|
||||
const listOnlyOrganization = {
|
||||
...MockOrganization,
|
||||
id: "org-t",
|
||||
name: "org-t",
|
||||
display_name: "Dev",
|
||||
};
|
||||
|
||||
export const NonOptionValueDisambiguatesOptions: Story = {
|
||||
args: {
|
||||
value: listOnlyOrganization,
|
||||
options: collidingDisplayNameOrganizations.slice(0, 1),
|
||||
labelOrganizations: [
|
||||
...collidingDisplayNameOrganizations.slice(0, 1),
|
||||
listOnlyOrganization,
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /Dev \(org-t\)/ }),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Dev (org-a)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("option", { name: /org-t/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackNameCollision: Story = {
|
||||
args: {
|
||||
value: null,
|
||||
options: [
|
||||
{ ...MockOrganization, name: "dev", display_name: "" },
|
||||
{ ...MockOrganization2, name: "org-b", display_name: "dev" },
|
||||
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: /^dev$/ })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "dev (org-b)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
value: MockOrganization2,
|
||||
|
||||
@@ -22,25 +22,54 @@ type OrganizationAutocompleteProps = {
|
||||
value: Organization | null;
|
||||
onChange: (organization: Organization | null) => void;
|
||||
options: readonly Organization[];
|
||||
// Collision set for disambiguation labels. Lets callers include
|
||||
// organizations that are visible but not selectable, such as a
|
||||
// selected organization missing from options. Defaults to options.
|
||||
labelOrganizations?: readonly Organization[];
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const getOrganizationLabel = (
|
||||
organization: Organization,
|
||||
organizations: readonly Organization[],
|
||||
) => {
|
||||
const displayName = organization.display_name || organization.name;
|
||||
const hasCollidingDisplayName = organizations.some(
|
||||
(other) =>
|
||||
other.id !== organization.id &&
|
||||
(other.display_name || other.name) === displayName,
|
||||
);
|
||||
|
||||
if (hasCollidingDisplayName && organization.name !== displayName) {
|
||||
return `${displayName} (${organization.name})`;
|
||||
}
|
||||
return displayName;
|
||||
};
|
||||
|
||||
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
labelOrganizations,
|
||||
id,
|
||||
ariaLabel,
|
||||
required,
|
||||
disabled,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const labelContext = labelOrganizations ?? options;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
aria-required={required}
|
||||
data-testid="organization-autocomplete"
|
||||
@@ -53,7 +82,9 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
src={value.icon}
|
||||
fallback={value.display_name}
|
||||
/>
|
||||
<span className="truncate">{value.display_name}</span>
|
||||
<span className="truncate">
|
||||
{getOrganizationLabel(value, labelContext)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-content-secondary">
|
||||
@@ -87,7 +118,7 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
fallback={org.display_name}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{org.display_name || org.name}
|
||||
{getOrganizationLabel(org, labelContext)}
|
||||
</span>
|
||||
{value?.id === org.id && (
|
||||
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
|
||||
|
||||
@@ -29,7 +29,11 @@ export const Navbar: React.FC = () => {
|
||||
const canViewAISettings =
|
||||
permissions.viewAnyAIProvider ||
|
||||
permissions.viewAIGatewayKeys ||
|
||||
permissions.editDeploymentConfig;
|
||||
permissions.editDeploymentConfig ||
|
||||
permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.createAnyMCPServerConfig ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig;
|
||||
const canCreateChat = permissions.createChat;
|
||||
|
||||
const uniqueLinks = new Map<string, LinkConfig>();
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import type { TasksFilter } from "#/api/typesGenerated";
|
||||
import { AuthProvider } from "#/contexts/auth/AuthProvider";
|
||||
import { AISettingsIndexRedirect } from "#/router";
|
||||
import {
|
||||
MockBuildInfo,
|
||||
MockNoPermissions,
|
||||
MockTasks,
|
||||
MockUserMember,
|
||||
MockUserOwner,
|
||||
} from "#/testHelpers/entities";
|
||||
import { pixelWithDesktop, pixelWithTablet } from "#/testHelpers/pixel";
|
||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
} from "#/testHelpers/storybook";
|
||||
import { NavbarView } from "./NavbarView";
|
||||
|
||||
const tasksFilter: TasksFilter = {
|
||||
owner: MockUserOwner.username,
|
||||
};
|
||||
|
||||
const memberTasksFilter: TasksFilter = {
|
||||
owner: MockUserMember.username,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof NavbarView> = {
|
||||
title: "modules/dashboard/NavbarView",
|
||||
parameters: {
|
||||
@@ -91,6 +102,156 @@ export const ForOrgAdmin: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ForMCPUpdateOnlyAdmin: Story = {
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
pixel: { matrix: pixelWithDesktop },
|
||||
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
|
||||
user: MockUserMember,
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
updateAnyMCPServerConfig: true,
|
||||
},
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/" },
|
||||
routing: [
|
||||
{ path: "/", useStoryElement: true },
|
||||
{
|
||||
path: "/ai/settings",
|
||||
// Route elements render outside story decorators, so the
|
||||
// redirect needs its own AuthProvider; it reads the query
|
||||
// data seeded by withAuthProvider.
|
||||
element: (
|
||||
<AuthProvider>
|
||||
<AISettingsIndexRedirect />
|
||||
</AuthProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/ai/settings/mcp-servers",
|
||||
element: <h1>MCP servers</h1>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
args: {
|
||||
user: MockUserMember,
|
||||
adminPermissions: {
|
||||
canViewAISettings: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Admin settings" }),
|
||||
);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const aiSettingsLink = body.getByRole("menuitem", { name: "AI" });
|
||||
await expect(aiSettingsLink).toHaveAttribute("href", "/ai/settings");
|
||||
await userEvent.click(aiSettingsLink);
|
||||
await expect(
|
||||
await canvas.findByRole("heading", { name: "MCP servers" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ForMCPDeleteOnlyAdmin: Story = {
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
|
||||
user: MockUserMember,
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
deleteAnyMCPServerConfig: true,
|
||||
},
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/" },
|
||||
routing: [
|
||||
{ path: "/", useStoryElement: true },
|
||||
{
|
||||
path: "/ai/settings",
|
||||
element: (
|
||||
<AuthProvider>
|
||||
<AISettingsIndexRedirect />
|
||||
</AuthProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/ai/settings/mcp-servers",
|
||||
element: <h1>MCP servers</h1>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
args: {
|
||||
user: MockUserMember,
|
||||
adminPermissions: {
|
||||
canViewAISettings: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Admin settings" }),
|
||||
);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(body.getByRole("menuitem", { name: "AI" }));
|
||||
await expect(
|
||||
await canvas.findByRole("heading", { name: "MCP servers" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ForMCPCreateOnlyAdmin: Story = {
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
|
||||
user: MockUserMember,
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
createAnyMCPServerConfig: true,
|
||||
},
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/" },
|
||||
routing: [
|
||||
{ path: "/", useStoryElement: true },
|
||||
{
|
||||
path: "/ai/settings",
|
||||
// Route elements render outside story decorators, so the
|
||||
// redirect needs its own AuthProvider; it reads the query
|
||||
// data seeded by withAuthProvider.
|
||||
element: (
|
||||
<AuthProvider>
|
||||
<AISettingsIndexRedirect />
|
||||
</AuthProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/ai/settings/mcp-servers/add",
|
||||
element: <h1>Add MCP server</h1>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
args: {
|
||||
user: MockUserMember,
|
||||
adminPermissions: {
|
||||
canViewAISettings: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Admin settings" }),
|
||||
);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(body.getByRole("menuitem", { name: "AI" }));
|
||||
await expect(
|
||||
await canvas.findByRole("heading", { name: "Add MCP server" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ForSingleOrgOSSAdmin: Story = {
|
||||
parameters: { pixel: { matrix: pixelWithDesktop } },
|
||||
args: {
|
||||
|
||||
@@ -13,10 +13,6 @@ export const useDashboard = (): DashboardValue => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const getDefaultOrganizationId = (
|
||||
organizations: DashboardValue["organizations"],
|
||||
): string => organizations.find((org) => org.is_default)?.id ?? "";
|
||||
|
||||
export const getDefaultOrganizationName = (
|
||||
organizations: DashboardValue["organizations"],
|
||||
): string => organizations.find((org) => org.is_default)?.name ?? "";
|
||||
|
||||
@@ -97,6 +97,67 @@ export const NoUpdateTemplates: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPServersForUpdateOnlyAdmin: Story = {
|
||||
args: {
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
updateAnyMCPServerConfig: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: "MCP servers" }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPServersForDeleteOnlyAdmin: Story = {
|
||||
args: {
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
deleteAnyMCPServerConfig: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: "MCP servers" }),
|
||||
).toHaveAttribute("href", "/ai/settings/mcp-servers");
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPServersForCreateOnlyAdmin: Story = {
|
||||
args: {
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
createAnyMCPServerConfig: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: "MCP servers" }),
|
||||
).toHaveAttribute("href", "/ai/settings/mcp-servers/add");
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPServersHiddenWithoutPermission: Story = {
|
||||
args: {
|
||||
permissions: {
|
||||
...MockNoPermissions,
|
||||
editDeploymentConfig: false,
|
||||
viewAnyMCPServerConfigs: false,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: "MCP servers" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPermissions: Story = {
|
||||
args: {
|
||||
permissions: MockNoPermissions,
|
||||
|
||||
@@ -72,6 +72,25 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!permissions.editDeploymentConfig &&
|
||||
(permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.createAnyMCPServerConfig ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig) && (
|
||||
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
|
||||
<SubNavItem
|
||||
href={
|
||||
permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig
|
||||
? "/ai/settings/mcp-servers"
|
||||
: "/ai/settings/mcp-servers/add"
|
||||
}
|
||||
>
|
||||
MCP servers
|
||||
</SubNavItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseSidebar>
|
||||
);
|
||||
|
||||
@@ -115,6 +115,34 @@ export const organizationPermissionChecks = (organizationId: string) =>
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
viewMCPServerConfigs: {
|
||||
object: {
|
||||
resource_type: "mcp_server_config",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
createMCPServerConfig: {
|
||||
object: {
|
||||
resource_type: "mcp_server_config",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
updateMCPServerConfig: {
|
||||
object: {
|
||||
resource_type: "mcp_server_config",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
deleteMCPServerConfig: {
|
||||
object: {
|
||||
resource_type: "mcp_server_config",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "delete",
|
||||
},
|
||||
}) as const satisfies Record<string, AuthorizationCheck>;
|
||||
|
||||
/**
|
||||
|
||||
+144
-23
@@ -1,42 +1,163 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import { createMCPServerConfig } from "#/api/queries/chats";
|
||||
import { organizationsPermissions } from "#/api/queries/organizations";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import {
|
||||
getDefaultOrganizationId,
|
||||
useDashboard,
|
||||
} from "#/modules/dashboard/useDashboard";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import {
|
||||
mcpServersPath,
|
||||
orgSearchParam,
|
||||
selectOrganization,
|
||||
updateMCPServerPath,
|
||||
} from "../organizationParam";
|
||||
import AddMCPServerPageView from "./AddMCPServerPageView";
|
||||
|
||||
const AddMCPServerPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { organizations } = useDashboard();
|
||||
const organization = getDefaultOrganizationId(organizations);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const organizationPermissionsQuery = useQuery({
|
||||
...organizationsPermissions(
|
||||
organizations.map((organization) => organization.id),
|
||||
),
|
||||
enabled: !permissions.editDeploymentConfig,
|
||||
});
|
||||
const listableOrganizations = permissions.editDeploymentConfig
|
||||
? organizations
|
||||
: organizations.filter((organization) => {
|
||||
const organizationPermissions =
|
||||
organizationPermissionsQuery.data?.[organization.id];
|
||||
return Boolean(
|
||||
organizationPermissions?.viewMCPServerConfigs ||
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
});
|
||||
const creatableOrganizations = permissions.editDeploymentConfig
|
||||
? organizations
|
||||
: organizations.filter(
|
||||
(organization) =>
|
||||
organizationPermissionsQuery.data?.[organization.id]
|
||||
.createMCPServerConfig,
|
||||
);
|
||||
const requestedOrganizationName = searchParams.get(orgSearchParam);
|
||||
const requestedOrganization =
|
||||
listableOrganizations.find(
|
||||
(organization) => organization.name === requestedOrganizationName,
|
||||
) ??
|
||||
creatableOrganizations.find(
|
||||
(organization) => organization.name === requestedOrganizationName,
|
||||
);
|
||||
const requestedOrganizationDenied =
|
||||
requestedOrganizationName !== null && !requestedOrganization;
|
||||
const organization =
|
||||
requestedOrganization ??
|
||||
(requestedOrganizationName === null && creatableOrganizations.length > 0
|
||||
? selectOrganization(creatableOrganizations, null)
|
||||
: undefined);
|
||||
useEffect(() => {
|
||||
if (requestedOrganizationName !== null || !organization) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set(orgSearchParam, organization.name);
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [organization, requestedOrganizationName, searchParams, setSearchParams]);
|
||||
const organizationPermissions = organization
|
||||
? organizationPermissionsQuery.data?.[organization.id]
|
||||
: undefined;
|
||||
const canViewServerList =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(
|
||||
organizationPermissions?.viewMCPServerConfigs ||
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
const canCreate =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(organizationPermissions?.createMCPServerConfig);
|
||||
const canOpenServer =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
const canOpenAnyServerList =
|
||||
permissions.editDeploymentConfig ||
|
||||
permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig;
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const createMutation = useMutation(
|
||||
createMCPServerConfig(queryClient, organization),
|
||||
createMCPServerConfig(queryClient, organization?.id ?? ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<AddMCPServerPageView
|
||||
isSaving={createMutation.isPending}
|
||||
onCancel={() => void navigate("/ai/settings/mcp-servers")}
|
||||
onCreateServer={async (req) => {
|
||||
try {
|
||||
const server = await createMutation.mutateAsync(req);
|
||||
toast.success(`MCP server "${server.display_name}" added.`);
|
||||
await navigate(`/ai/settings/mcp-servers/${server.id}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Failed to add MCP server."));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RequirePermission
|
||||
isFeatureVisible={
|
||||
permissions.editDeploymentConfig || permissions.createAnyMCPServerConfig
|
||||
}
|
||||
>
|
||||
{organizationPermissionsQuery.isLoadingError ? (
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
) : !permissions.editDeploymentConfig &&
|
||||
!organizationPermissionsQuery.data ? (
|
||||
<Loader />
|
||||
) : creatableOrganizations.length === 0 || requestedOrganizationDenied ? (
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
) : (
|
||||
organization && (
|
||||
<>
|
||||
{organizationPermissionsQuery.isRefetchError && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
</div>
|
||||
)}
|
||||
<AddMCPServerPageView
|
||||
isSaving={createMutation.isPending}
|
||||
canCreate={canCreate}
|
||||
canSelectUserOIDC={permissions.editDeploymentConfig}
|
||||
canViewServerList={canOpenAnyServerList}
|
||||
organizations={creatableOrganizations}
|
||||
organization={organization}
|
||||
onSelectOrganization={(org) => {
|
||||
setSearchParams((params) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(orgSearchParam, org.name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onCancel={() => void navigate(mcpServersPath(organization))}
|
||||
onCreateServer={async (req) => {
|
||||
try {
|
||||
const server = await createMutation.mutateAsync(req);
|
||||
toast.success(`MCP server "${server.display_name}" added.`);
|
||||
if (canOpenServer) {
|
||||
await navigate(
|
||||
updateMCPServerPath(server.id, organization),
|
||||
);
|
||||
} else if (canViewServerList) {
|
||||
await navigate(mcpServersPath(organization));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to add MCP server."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
+39
-3
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
|
||||
import { MockDefaultOrganization } from "#/testHelpers/entities";
|
||||
import AddMCPServerPageView from "./AddMCPServerPageView";
|
||||
|
||||
const meta: Meta<typeof AddMCPServerPageView> = {
|
||||
@@ -10,9 +10,14 @@ const meta: Meta<typeof AddMCPServerPageView> = {
|
||||
component: AddMCPServerPageView,
|
||||
args: {
|
||||
isSaving: false,
|
||||
canCreate: true,
|
||||
canSelectUserOIDC: true,
|
||||
canViewServerList: true,
|
||||
organizations: [MockDefaultOrganization],
|
||||
organization: MockDefaultOrganization,
|
||||
onSelectOrganization: fn(),
|
||||
onCreateServer: fn(
|
||||
async (req: TypesGen.CreateMCPServerConfigRequest) =>
|
||||
({ ...MockMCPServerConfig, ...req }) as TypesGen.MCPServerConfig,
|
||||
async (_req: TypesGen.CreateMCPServerConfigRequest) => true,
|
||||
),
|
||||
onCancel: fn(),
|
||||
},
|
||||
@@ -32,6 +37,11 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
const addButton = canvas.getByRole("button", { name: "Add server" });
|
||||
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(addButton).toBeDisabled();
|
||||
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
|
||||
await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("github");
|
||||
@@ -62,5 +72,31 @@ export const Default: Story = {
|
||||
}),
|
||||
);
|
||||
});
|
||||
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("");
|
||||
await expect(canvas.getByLabelText(/^slug/i)).toHaveValue("");
|
||||
await expect(canvas.getByLabelText(/server url/i)).toHaveValue("");
|
||||
await expect(addButton).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const UserOIDCRequiresDeploymentPermission: Story = {
|
||||
args: {
|
||||
canSelectUserOIDC: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /authentication/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("combobox", { name: /authentication method/i }),
|
||||
);
|
||||
await expect(
|
||||
body.getByRole("option", { name: "OAuth2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
body.queryByRole("option", { name: "User OIDC identity" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+41
-4
@@ -1,10 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { MCPServerForm } from "../components/MCPServerForm";
|
||||
import { OrganizationPicker } from "../components/OrganizationPicker";
|
||||
import { mcpServersPath } from "../organizationParam";
|
||||
|
||||
interface AddMCPServerPageViewProps {
|
||||
isSaving: boolean;
|
||||
canCreate: boolean;
|
||||
canViewServerList: boolean;
|
||||
canSelectUserOIDC: boolean;
|
||||
organizations: readonly TypesGen.Organization[];
|
||||
organization: TypesGen.Organization;
|
||||
onSelectOrganization: (organization: TypesGen.Organization) => void;
|
||||
onCreateServer: (
|
||||
req: TypesGen.CreateMCPServerConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
@@ -13,17 +22,45 @@ interface AddMCPServerPageViewProps {
|
||||
|
||||
const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
|
||||
isSaving,
|
||||
canCreate,
|
||||
canViewServerList,
|
||||
canSelectUserOIDC,
|
||||
organizations,
|
||||
organization,
|
||||
onSelectOrganization,
|
||||
onCreateServer,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("Add server", "AI Settings")}</title>
|
||||
<MCPServerForm
|
||||
isSaving={isSaving}
|
||||
onCreateServer={onCreateServer}
|
||||
onCancel={onCancel}
|
||||
<OrganizationPicker
|
||||
id="mcp-add-organization"
|
||||
className="mb-6"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
disabled={isSaving}
|
||||
showSingleOrganization
|
||||
/>
|
||||
{canCreate ? (
|
||||
<MCPServerForm
|
||||
listPath={
|
||||
canViewServerList ? mcpServersPath(organization) : undefined
|
||||
}
|
||||
isSaving={isSaving}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
onCreateServer={onCreateServer}
|
||||
onCancel={canViewServerList ? onCancel : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Alert severity="error" prominent>
|
||||
<AlertTitle>You cannot add servers to this organization</AlertTitle>
|
||||
<AlertDescription>
|
||||
Choose an organization where you have permission to add MCP servers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,123 @@
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { mcpServerConfigs } from "#/api/queries/chats";
|
||||
import { organizationsPermissions } from "#/api/queries/organizations";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import {
|
||||
getDefaultOrganizationId,
|
||||
useDashboard,
|
||||
} from "#/modules/dashboard/useDashboard";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import MCPServersPageView from "./MCPServersPageView";
|
||||
import { orgSearchParam, selectOrganization } from "./organizationParam";
|
||||
|
||||
const MCPServersPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { organizations } = useDashboard();
|
||||
const organization = getDefaultOrganizationId(organizations);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const organizationPermissionsQuery = useQuery({
|
||||
...organizationsPermissions(
|
||||
organizations.map((organization) => organization.id),
|
||||
),
|
||||
enabled: !permissions.editDeploymentConfig,
|
||||
});
|
||||
const authorizedOrganizations = permissions.editDeploymentConfig
|
||||
? organizations
|
||||
: organizations.filter((organization) => {
|
||||
const organizationPermissions =
|
||||
organizationPermissionsQuery.data?.[organization.id];
|
||||
return Boolean(
|
||||
organizationPermissions?.viewMCPServerConfigs ||
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
});
|
||||
const creatableOrganizations = permissions.editDeploymentConfig
|
||||
? organizations
|
||||
: organizations.filter(
|
||||
(organization) =>
|
||||
organizationPermissionsQuery.data?.[organization.id]
|
||||
.createMCPServerConfig,
|
||||
);
|
||||
const organization =
|
||||
authorizedOrganizations.length > 0
|
||||
? selectOrganization(
|
||||
authorizedOrganizations,
|
||||
searchParams.get(orgSearchParam),
|
||||
)
|
||||
: undefined;
|
||||
const addOrganization =
|
||||
creatableOrganizations.length > 0
|
||||
? selectOrganization(creatableOrganizations, organization?.name ?? null)
|
||||
: undefined;
|
||||
const organizationPermissions = organization
|
||||
? organizationPermissionsQuery.data?.[organization.id]
|
||||
: undefined;
|
||||
const canView =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(
|
||||
organizationPermissions?.viewMCPServerConfigs ||
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
const canOpenServer =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
const serversQuery = useQuery({
|
||||
...mcpServerConfigs(organization),
|
||||
enabled: Boolean(organization),
|
||||
...mcpServerConfigs(organization?.id ?? ""),
|
||||
enabled: canView,
|
||||
});
|
||||
const servers = (serversQuery.data ?? []).toSorted((a, b) =>
|
||||
a.display_name.localeCompare(b.display_name),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<RequirePermission
|
||||
isFeatureVisible={
|
||||
permissions.editDeploymentConfig ||
|
||||
permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig
|
||||
}
|
||||
>
|
||||
<title>{pageTitle("MCP servers", "AI Settings")}</title>
|
||||
<MCPServersPageView
|
||||
isLoading={serversQuery.isLoading}
|
||||
error={serversQuery.error}
|
||||
servers={servers}
|
||||
/>
|
||||
{organizationPermissionsQuery.isLoadingError ? (
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
) : !permissions.editDeploymentConfig &&
|
||||
!organizationPermissionsQuery.data ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<RequirePermission isFeatureVisible={canView && Boolean(organization)}>
|
||||
{organizationPermissionsQuery.isRefetchError && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
</div>
|
||||
)}
|
||||
{organization && (
|
||||
<MCPServersPageView
|
||||
isLoading={serversQuery.isLoading}
|
||||
error={serversQuery.error}
|
||||
servers={servers}
|
||||
organizations={authorizedOrganizations}
|
||||
organization={organization}
|
||||
addOrganization={addOrganization}
|
||||
addOrganizations={creatableOrganizations}
|
||||
canOpenServer={canOpenServer}
|
||||
onSelectOrganization={(org) => {
|
||||
setSearchParams((params) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(orgSearchParam, org.name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RequirePermission>
|
||||
)}
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization2,
|
||||
} from "#/testHelpers/entities";
|
||||
import MCPServersPageView from "./MCPServersPageView";
|
||||
import {
|
||||
MockCoderMCPServer,
|
||||
@@ -21,6 +25,11 @@ const meta: Meta<typeof MCPServersPageView> = {
|
||||
MockImageMCPServer,
|
||||
MockMemoryMCPServer,
|
||||
],
|
||||
organizations: [MockDefaultOrganization],
|
||||
organization: MockDefaultOrganization,
|
||||
addOrganization: MockDefaultOrganization,
|
||||
canOpenServer: true,
|
||||
onSelectOrganization: fn(),
|
||||
},
|
||||
parameters: {
|
||||
// TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude.
|
||||
@@ -73,6 +82,22 @@ export const Empty: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleOrganizations: Story = {
|
||||
args: {
|
||||
organizations: [MockDefaultOrganization, MockOrganization2],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const picker = canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
});
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker).toHaveTextContent(
|
||||
MockDefaultOrganization.display_name,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadError: Story = {
|
||||
args: {
|
||||
error: new Error("Failed to load MCP servers"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { getOrganizationLabel } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
@@ -19,29 +20,63 @@ import {
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import { MCPServerRow } from "./components/MCPServerRow";
|
||||
import { OrganizationPicker } from "./components/OrganizationPicker";
|
||||
import { addMCPServerPath, updateMCPServerPath } from "./organizationParam";
|
||||
|
||||
interface MCPServersPageViewProps {
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
servers: readonly TypesGen.MCPServerConfig[];
|
||||
organizations: readonly TypesGen.Organization[];
|
||||
organization: TypesGen.Organization;
|
||||
addOrganization?: TypesGen.Organization;
|
||||
addOrganizations: readonly TypesGen.Organization[];
|
||||
canOpenServer: boolean;
|
||||
onSelectOrganization: (organization: TypesGen.Organization) => void;
|
||||
}
|
||||
|
||||
const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
isLoading,
|
||||
error,
|
||||
servers,
|
||||
organizations,
|
||||
organization,
|
||||
addOrganization,
|
||||
addOrganizations,
|
||||
canOpenServer,
|
||||
onSelectOrganization,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const goToAddServer = () => void navigate("/ai/settings/mcp-servers/add");
|
||||
// Disambiguate against every organization sharing the page context:
|
||||
// other creation targets and the currently selected organization.
|
||||
const addButtonLabel =
|
||||
addOrganization && addOrganization.id !== organization.id
|
||||
? `Add server to ${getOrganizationLabel(addOrganization, [
|
||||
...addOrganizations,
|
||||
organization,
|
||||
])}`
|
||||
: undefined;
|
||||
const goToAddServer = () => {
|
||||
if (addOrganization) {
|
||||
void navigate(addMCPServerPath(addOrganization));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={goToAddServer}>
|
||||
<PlusIcon />
|
||||
<span>Add server</span>
|
||||
</Button>
|
||||
addOrganization && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToAddServer}
|
||||
aria-label={addButtonLabel}
|
||||
title={addButtonLabel}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>Add server</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsHeaderTitle>MCP servers</SettingsHeaderTitle>
|
||||
@@ -50,6 +85,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
Agents.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<OrganizationPicker
|
||||
id="mcp-servers-organization"
|
||||
className="mb-4"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={error} />
|
||||
@@ -75,10 +117,17 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
message="No MCP servers configured"
|
||||
description="Add a server to give agents access to external tools."
|
||||
cta={
|
||||
<Button variant="outline" onClick={goToAddServer}>
|
||||
<PlusIcon />
|
||||
<span>Add server</span>
|
||||
</Button>
|
||||
addOrganization ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToAddServer}
|
||||
aria-label={addButtonLabel}
|
||||
title={addButtonLabel}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>Add server</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -86,8 +135,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
<MCPServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
onClick={() =>
|
||||
void navigate(`/ai/settings/mcp-servers/${server.id}`)
|
||||
onClick={
|
||||
canOpenServer
|
||||
? () =>
|
||||
void navigate(
|
||||
updateMCPServerPath(server.id, organization),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
||||
+195
-73
@@ -1,103 +1,225 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Navigate, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Navigate,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import { getErrorMessage, isApiError } from "#/api/errors";
|
||||
import {
|
||||
deleteMCPServerConfig,
|
||||
mcpServerConfigs,
|
||||
mcpServerConfig,
|
||||
updateMCPServerConfig,
|
||||
} from "#/api/queries/chats";
|
||||
import { organizationsPermissions } from "#/api/queries/organizations";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import {
|
||||
getDefaultOrganizationId,
|
||||
useDashboard,
|
||||
} from "#/modules/dashboard/useDashboard";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import {
|
||||
mcpServersPath,
|
||||
orgSearchParam,
|
||||
selectOrganization,
|
||||
} from "../organizationParam";
|
||||
import UpdateMCPServerPageView from "./UpdateMCPServerPageView";
|
||||
|
||||
const UpdateMCPServerPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { organizations } = useDashboard();
|
||||
const organization = getDefaultOrganizationId(organizations);
|
||||
const { serverId } = useParams<{ serverId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const organizationPermissionsQuery = useQuery({
|
||||
...organizationsPermissions(
|
||||
organizations.map((organization) => organization.id),
|
||||
),
|
||||
enabled: !permissions.editDeploymentConfig,
|
||||
});
|
||||
const manageableOrganizations = permissions.editDeploymentConfig
|
||||
? organizations
|
||||
: organizations.filter((organization) => {
|
||||
const organizationPermissions =
|
||||
organizationPermissionsQuery.data?.[organization.id];
|
||||
return Boolean(
|
||||
organizationPermissions?.updateMCPServerConfig ||
|
||||
organizationPermissions?.deleteMCPServerConfig,
|
||||
);
|
||||
});
|
||||
const requestedOrganizationName = searchParams.get(orgSearchParam);
|
||||
const organization =
|
||||
requestedOrganizationName === null
|
||||
? manageableOrganizations.length > 0
|
||||
? selectOrganization(manageableOrganizations, null)
|
||||
: undefined
|
||||
: manageableOrganizations.find(
|
||||
(organization) => organization.name === requestedOrganizationName,
|
||||
);
|
||||
const organizationPermissions = organization
|
||||
? organizationPermissionsQuery.data?.[organization.id]
|
||||
: undefined;
|
||||
const canUpdate =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(organizationPermissions?.updateMCPServerConfig);
|
||||
const canDelete =
|
||||
permissions.editDeploymentConfig ||
|
||||
Boolean(organizationPermissions?.deleteMCPServerConfig);
|
||||
const canManage = canUpdate || canDelete;
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const serversQuery = useQuery({
|
||||
...mcpServerConfigs(organization),
|
||||
enabled: Boolean(organization),
|
||||
const serverQuery = useQuery({
|
||||
...mcpServerConfig(organization?.id ?? "", serverId ?? ""),
|
||||
enabled: Boolean(serverId) && canManage,
|
||||
});
|
||||
const server = serversQuery.data?.find((item) => item.id === serverId);
|
||||
const serverOrganization = server?.organization_id ?? organization;
|
||||
const server = serverQuery.data;
|
||||
// The backend rejects any update touching a user_oidc server without
|
||||
// deployment permission, so org-level update grants cannot manage them.
|
||||
const canUpdateServer =
|
||||
permissions.editDeploymentConfig ||
|
||||
(canUpdate && server?.auth_type !== "user_oidc");
|
||||
const updateMutation = useMutation(
|
||||
updateMCPServerConfig(queryClient, serverOrganization),
|
||||
updateMCPServerConfig(queryClient, organization?.id ?? ""),
|
||||
);
|
||||
const deleteMutation = useMutation(
|
||||
deleteMCPServerConfig(queryClient, serverOrganization),
|
||||
deleteMCPServerConfig(queryClient, organization?.id ?? ""),
|
||||
);
|
||||
// A 404 must win over cached data: a refetch failure keeps stale data,
|
||||
// which would otherwise render a form for a deleted or concealed server.
|
||||
const notFound =
|
||||
serverQuery.isError &&
|
||||
isApiError(serverQuery.error) &&
|
||||
serverQuery.error.response.status === 404;
|
||||
const listPath = organization
|
||||
? mcpServersPath(organization)
|
||||
: "/ai/settings/mcp-servers";
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
{!serverId ? (
|
||||
<Navigate to="/ai/settings/mcp-servers" replace />
|
||||
) : serversQuery.isLoading ? (
|
||||
<>
|
||||
<title>{pageTitle("Loading...", "AI Settings")}</title>
|
||||
<Loader fullscreen />
|
||||
</>
|
||||
) : !server ? (
|
||||
<Navigate to="/ai/settings/mcp-servers" replace />
|
||||
<RequirePermission
|
||||
isFeatureVisible={
|
||||
permissions.editDeploymentConfig ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig
|
||||
}
|
||||
>
|
||||
{organizationPermissionsQuery.isLoadingError ? (
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
) : !permissions.editDeploymentConfig &&
|
||||
!organizationPermissionsQuery.data ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<UpdateMCPServerPageView
|
||||
server={server}
|
||||
isSaving={updateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onCancel={() => void navigate("/ai/settings/mcp-servers")}
|
||||
onUpdateServer={async (id, req) => {
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync({ id, req });
|
||||
toast.success(`MCP server "${updated.display_name}" updated.`);
|
||||
await navigate("/ai/settings/mcp-servers");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to update MCP server."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDeleteServer={async (id) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(`MCP server "${server.display_name}" deleted.`);
|
||||
await navigate("/ai/settings/mcp-servers", { replace: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to delete MCP server."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onToggleEnabled={(enabled) => {
|
||||
updateMutation.mutate(
|
||||
{ id: server.id, req: { enabled } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to ${enabled ? "enable" : "disable"} MCP server.`,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<RequirePermission
|
||||
isFeatureVisible={canManage && Boolean(organization)}
|
||||
>
|
||||
{organizationPermissionsQuery.isRefetchError && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={organizationPermissionsQuery.error} />
|
||||
</div>
|
||||
)}
|
||||
{!serverId ? (
|
||||
<Navigate to={listPath} replace />
|
||||
) : serverQuery.isLoading ? (
|
||||
<>
|
||||
<title>{pageTitle("Loading...", "AI Settings")}</title>
|
||||
<Loader fullscreen />
|
||||
</>
|
||||
) : serverQuery.isLoadingError && !notFound ? (
|
||||
<>
|
||||
<title>{pageTitle("MCP servers", "AI Settings")}</title>
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={serverQuery.error} />
|
||||
</div>
|
||||
</>
|
||||
) : notFound || !server || !organization ? (
|
||||
<Navigate to={listPath} replace />
|
||||
) : (
|
||||
<>
|
||||
{serverQuery.isRefetchError && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={serverQuery.error} />
|
||||
</div>
|
||||
)}
|
||||
<UpdateMCPServerPageView
|
||||
server={server}
|
||||
organizations={manageableOrganizations}
|
||||
organization={organization}
|
||||
listPath={listPath}
|
||||
isSaving={updateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
canSelectUserOIDC={permissions.editDeploymentConfig}
|
||||
onCancel={() => void navigate(listPath)}
|
||||
onUpdateServer={
|
||||
canUpdateServer
|
||||
? async (id, req) => {
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync({
|
||||
id,
|
||||
req,
|
||||
});
|
||||
toast.success(
|
||||
`MCP server "${updated.display_name}" updated.`,
|
||||
);
|
||||
await navigate(listPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to update MCP server.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDeleteServer={
|
||||
canDelete
|
||||
? async (id) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(
|
||||
`MCP server "${server.display_name}" deleted.`,
|
||||
);
|
||||
await navigate(listPath, { replace: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to delete MCP server.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onToggleEnabled={
|
||||
canUpdateServer
|
||||
? (enabled) => {
|
||||
updateMutation.mutate(
|
||||
{ id: server.id, req: { enabled } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`MCP server "${server.display_name}" ${enabled ? "enabled" : "disabled"}.`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to ${enabled ? "enable" : "disable"} MCP server.`,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</RequirePermission>
|
||||
)}
|
||||
</RequirePermission>
|
||||
);
|
||||
|
||||
+70
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockDefaultOrganization } from "#/testHelpers/entities";
|
||||
import { MockCoderMCPServer } from "../testFixtures";
|
||||
import UpdateMCPServerPageView from "./UpdateMCPServerPageView";
|
||||
|
||||
@@ -17,7 +18,11 @@ const meta: Meta<typeof UpdateMCPServerPageView> = {
|
||||
component: UpdateMCPServerPageView,
|
||||
args: {
|
||||
server: MockCoderMCPServer,
|
||||
organizations: [MockDefaultOrganization],
|
||||
organization: MockDefaultOrganization,
|
||||
listPath: "/ai/settings/mcp-servers",
|
||||
isSaving: false,
|
||||
canSelectUserOIDC: true,
|
||||
isDeleting: false,
|
||||
onUpdateServer,
|
||||
onDeleteServer: fn(async () => undefined),
|
||||
@@ -39,6 +44,11 @@ export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /authentication/i }),
|
||||
@@ -63,3 +73,63 @@ export const Default: Story = {
|
||||
expect(onUpdateServer.mock.calls[0]?.[1]).not.toHaveProperty("enabled");
|
||||
},
|
||||
};
|
||||
|
||||
export const UserOIDCRequiresDeploymentPermission: Story = {
|
||||
args: {
|
||||
canSelectUserOIDC: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /authentication/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("combobox", { name: /authentication method/i }),
|
||||
);
|
||||
await expect(
|
||||
body.getByRole("option", { name: "OAuth2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
body.queryByRole("option", { name: "User OIDC identity" }),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText(/display name/i)).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteOnly: Story = {
|
||||
args: {
|
||||
onUpdateServer: undefined,
|
||||
onToggleEnabled: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
|
||||
await expect(canvas.getByLabelText(/^slug/i)).toBeDisabled();
|
||||
await expect(canvas.getByLabelText(/server url/i)).toBeDisabled();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Update server" }),
|
||||
).toBeDisabled();
|
||||
const serverActions = canvas.getByRole("button", {
|
||||
name: "Server actions",
|
||||
});
|
||||
const enabledSwitch = canvas.getByRole("switch", {
|
||||
name: "Server enabled",
|
||||
});
|
||||
await expect(enabledSwitch).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(enabledSwitch).toHaveAccessibleDescription(
|
||||
"You do not have permission to update this server.",
|
||||
);
|
||||
serverActions.focus();
|
||||
await userEvent.tab();
|
||||
await expect(enabledSwitch).toHaveFocus();
|
||||
await expect(await body.findByRole("tooltip")).toHaveTextContent(
|
||||
"You do not have permission to update this server.",
|
||||
);
|
||||
await userEvent.click(serverActions);
|
||||
await expect(
|
||||
await body.findByRole("menuitem", { name: "Remove" }),
|
||||
).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
+21
-3
@@ -2,24 +2,33 @@ import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { MCPServerForm } from "../components/MCPServerForm";
|
||||
import { OrganizationPicker } from "../components/OrganizationPicker";
|
||||
|
||||
interface UpdateMCPServerPageViewProps {
|
||||
server: TypesGen.MCPServerConfig;
|
||||
organizations: readonly TypesGen.Organization[];
|
||||
organization: TypesGen.Organization;
|
||||
listPath: string;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
onUpdateServer: (
|
||||
canSelectUserOIDC: boolean;
|
||||
onUpdateServer?: (
|
||||
serverId: string,
|
||||
req: TypesGen.UpdateMCPServerConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteServer: (serverId: string) => Promise<void>;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onDeleteServer?: (serverId: string) => Promise<void>;
|
||||
onToggleEnabled?: (enabled: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
|
||||
server,
|
||||
organizations,
|
||||
organization,
|
||||
listPath,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
canSelectUserOIDC,
|
||||
onUpdateServer,
|
||||
onDeleteServer,
|
||||
onToggleEnabled,
|
||||
@@ -28,11 +37,20 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle(server.display_name, "AI Settings")}</title>
|
||||
<OrganizationPicker
|
||||
id="mcp-update-organization"
|
||||
className="mb-6"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
showSingleOrganization
|
||||
/>
|
||||
<MCPServerForm
|
||||
key={server.id}
|
||||
server={server}
|
||||
listPath={listPath}
|
||||
isSaving={isSaving}
|
||||
isDeleting={isDeleting}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
onUpdateServer={onUpdateServer}
|
||||
onDeleteServer={onDeleteServer}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
|
||||
@@ -17,17 +17,29 @@ import {
|
||||
SECRET_PLACEHOLDER,
|
||||
} from "./mcpServerFormLogic";
|
||||
|
||||
interface MCPServerAuthSectionProps {
|
||||
interface MCPServerAuthFieldsProps {
|
||||
form: FormikContextType<MCPServerFormValues>;
|
||||
formId: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface MCPServerAuthSectionProps extends MCPServerAuthFieldsProps {
|
||||
canSelectUserOIDC: boolean;
|
||||
}
|
||||
|
||||
export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
|
||||
form,
|
||||
formId,
|
||||
disabled,
|
||||
canSelectUserOIDC,
|
||||
}) => {
|
||||
const authTypeOptions = AUTH_TYPE_OPTIONS.filter(
|
||||
(option) =>
|
||||
option.value !== "user_oidc" ||
|
||||
canSelectUserOIDC ||
|
||||
form.initialValues.authType === "user_oidc",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
@@ -44,7 +56,7 @@ export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_TYPE_OPTIONS.map((option) => (
|
||||
{authTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
@@ -70,7 +82,7 @@ export const MCPServerAuthSection: FC<MCPServerAuthSectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const OAuth2Fields: FC<MCPServerAuthSectionProps> = ({
|
||||
const OAuth2Fields: FC<MCPServerAuthFieldsProps> = ({
|
||||
form,
|
||||
formId,
|
||||
disabled,
|
||||
@@ -144,7 +156,7 @@ const OAuth2Fields: FC<MCPServerAuthSectionProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const APIKeyFields: FC<MCPServerAuthSectionProps> = ({
|
||||
const APIKeyFields: FC<MCPServerAuthFieldsProps> = ({
|
||||
form,
|
||||
formId,
|
||||
disabled,
|
||||
@@ -212,7 +224,7 @@ const SecretInput: FC<{
|
||||
/>
|
||||
);
|
||||
|
||||
const CustomHeadersFields: FC<MCPServerAuthSectionProps> = ({
|
||||
const CustomHeadersFields: FC<MCPServerAuthFieldsProps> = ({
|
||||
form,
|
||||
formId,
|
||||
disabled,
|
||||
|
||||
@@ -15,23 +15,29 @@ import {
|
||||
|
||||
type MCPServerFormCreateProps = {
|
||||
server?: undefined;
|
||||
// Create-only callers cannot open the server list, so the back link and
|
||||
// cancel action are omitted rather than pointing at a denied page.
|
||||
listPath?: string;
|
||||
isSaving: boolean;
|
||||
isDeleting?: false;
|
||||
canSelectUserOIDC: boolean;
|
||||
onCreateServer: (
|
||||
req: TypesGen.CreateMCPServerConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onUpdateServer?: undefined;
|
||||
onDeleteServer?: undefined;
|
||||
onToggleEnabled?: undefined;
|
||||
onCancel: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
type MCPServerFormEditProps = {
|
||||
server: TypesGen.MCPServerConfig;
|
||||
listPath: string;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
canSelectUserOIDC: boolean;
|
||||
onCreateServer?: undefined;
|
||||
onUpdateServer: (
|
||||
onUpdateServer?: (
|
||||
serverId: string,
|
||||
req: TypesGen.UpdateMCPServerConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
@@ -44,8 +50,10 @@ type MCPServerFormProps = MCPServerFormCreateProps | MCPServerFormEditProps;
|
||||
|
||||
export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
server,
|
||||
listPath,
|
||||
isSaving,
|
||||
isDeleting = false,
|
||||
canSelectUserOIDC,
|
||||
onCreateServer,
|
||||
onUpdateServer,
|
||||
onDeleteServer,
|
||||
@@ -69,13 +77,20 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
buildUpdateMCPServerConfigRequest(values),
|
||||
);
|
||||
} else if (onCreateServer) {
|
||||
await onCreateServer(buildCreateMCPServerConfigRequest(values));
|
||||
const created = await onCreateServer(
|
||||
buildCreateMCPServerConfigRequest(values),
|
||||
);
|
||||
if (created === true) {
|
||||
form.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isDisabled = isSaving || isDeleting;
|
||||
const canSubmit = canSubmitMCPServerForm(form.values, isDisabled);
|
||||
const areFieldsDisabled =
|
||||
isDisabled || (isEditing && onUpdateServer === undefined);
|
||||
const canSubmit = canSubmitMCPServerForm(form.values, areFieldsDisabled);
|
||||
const unsavedChanges = useUnsavedChangesPrompt(
|
||||
form.dirty && !form.isSubmitting,
|
||||
);
|
||||
@@ -89,18 +104,22 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
server={server}
|
||||
title={title}
|
||||
iconUrl={form.values.iconURL}
|
||||
listPath={listPath}
|
||||
isEditing={isEditing}
|
||||
isDisabled={isDisabled}
|
||||
onRequestDelete={() => setConfirmingDelete(true)}
|
||||
onRequestDelete={
|
||||
onDeleteServer ? () => setConfirmingDelete(true) : undefined
|
||||
}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-6 pt-6">
|
||||
<MCPServerFormFields
|
||||
form={form}
|
||||
isSaving={isSaving}
|
||||
isDisabled={isDisabled}
|
||||
isDisabled={areFieldsDisabled}
|
||||
canSubmit={canSubmit}
|
||||
isEditing={isEditing}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
onCancel={onCancel}
|
||||
showDetails={showDetails}
|
||||
setShowDetails={setShowDetails}
|
||||
|
||||
@@ -30,7 +30,8 @@ interface MCPServerFormFieldsProps {
|
||||
isDisabled: boolean;
|
||||
canSubmit: boolean;
|
||||
isEditing: boolean;
|
||||
onCancel: () => void;
|
||||
canSelectUserOIDC: boolean;
|
||||
onCancel?: () => void;
|
||||
showDetails: boolean;
|
||||
setShowDetails: (open: boolean) => void;
|
||||
showAuth: boolean;
|
||||
@@ -45,6 +46,7 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
isDisabled,
|
||||
canSubmit,
|
||||
isEditing,
|
||||
canSelectUserOIDC,
|
||||
onCancel,
|
||||
showDetails,
|
||||
setShowDetails,
|
||||
@@ -178,6 +180,7 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
form={form}
|
||||
formId={formId}
|
||||
disabled={isDisabled}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -198,14 +201,16 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!canSubmit} type="submit">
|
||||
<Spinner loading={isSaving} />
|
||||
{isEditing ? "Update server" : "Add server"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeftIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useId } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
import { cn } from "#/utils/cn";
|
||||
import { MCPServerIcon } from "./MCPServerIcon";
|
||||
|
||||
const MCPServerFormBackLink: FC = () => {
|
||||
const MCPServerFormBackLink: FC<{ to: string }> = ({ to }) => {
|
||||
return (
|
||||
<Link to="/ai/settings/mcp-servers" className="-ml-3">
|
||||
<Link to={to} className="-ml-3">
|
||||
<Button variant="subtle" type="button">
|
||||
<ArrowLeftIcon />
|
||||
<span>Back to MCP servers</span>
|
||||
@@ -35,9 +35,10 @@ interface MCPServerFormHeaderProps {
|
||||
server?: TypesGen.MCPServerConfig;
|
||||
title: string;
|
||||
iconUrl: string;
|
||||
listPath?: string;
|
||||
isEditing: boolean;
|
||||
isDisabled: boolean;
|
||||
onRequestDelete: () => void;
|
||||
onRequestDelete?: () => void;
|
||||
onToggleEnabled?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -45,16 +46,20 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
server,
|
||||
title,
|
||||
iconUrl,
|
||||
listPath,
|
||||
isEditing,
|
||||
isDisabled,
|
||||
onRequestDelete,
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const disabledReasonId = useId();
|
||||
const lacksUpdatePermission = !onToggleEnabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<MCPServerFormBackLink />
|
||||
{isEditing && server && (
|
||||
{listPath && <MCPServerFormBackLink to={listPath} />}
|
||||
{isEditing && server && onRequestDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -105,18 +110,34 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
<span className="inline-flex">
|
||||
<Switch
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(checked) => onToggleEnabled?.(checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (onToggleEnabled) {
|
||||
onToggleEnabled(checked);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
aria-disabled={lacksUpdatePermission}
|
||||
aria-describedby={
|
||||
lacksUpdatePermission ? disabledReasonId : undefined
|
||||
}
|
||||
aria-label="Server enabled"
|
||||
className="aria-disabled:cursor-not-allowed aria-disabled:data-[state=checked]:bg-surface-tertiary aria-disabled:data-[state=unchecked]:bg-surface-tertiary"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{server.enabled
|
||||
? "Disable this server. It will be hidden from agents."
|
||||
: "Enable this server. It will be visible to agents."}
|
||||
{lacksUpdatePermission
|
||||
? "You do not have permission to update this server."
|
||||
: server.enabled
|
||||
? "Disable this server. It will be hidden from agents."
|
||||
: "Enable this server. It will be visible to agents."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{lacksUpdatePermission && (
|
||||
<span id={disabledReasonId} className="sr-only">
|
||||
You do not have permission to update this server.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm">Enable</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,15 +10,17 @@ import { AUTH_TYPE_LABELS, AVAILABILITY_LABELS } from "./mcpServerFormLogic";
|
||||
|
||||
interface MCPServerRowProps {
|
||||
server: TypesGen.MCPServerConfig;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
|
||||
const clickableProps = useClickableTableRow({ onClick });
|
||||
const clickableProps = useClickableTableRow({
|
||||
onClick: () => onClick?.(),
|
||||
});
|
||||
const enabled = server.enabled;
|
||||
|
||||
return (
|
||||
<TableRow {...clickableProps}>
|
||||
<TableRow {...(onClick ? clickableProps : {})}>
|
||||
<TableCell className="min-w-0 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<MCPServerIcon
|
||||
@@ -46,7 +48,9 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
|
||||
<Badge variant="default">{enabled ? "Enabled" : "Disabled"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="w-12">
|
||||
<ChevronRightIcon className="size-5 text-content-primary" />
|
||||
{onClick && (
|
||||
<ChevronRightIcon className="size-5 text-content-primary" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FC } from "react";
|
||||
import type { Organization } from "#/api/typesGenerated";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
getOrganizationLabel,
|
||||
OrganizationAutocomplete,
|
||||
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
interface OrganizationPickerProps {
|
||||
id: string;
|
||||
organizations: readonly Organization[];
|
||||
organization: Organization;
|
||||
onChange?: (organization: Organization) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showSingleOrganization?: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationPicker: FC<OrganizationPickerProps> = ({
|
||||
id,
|
||||
organizations,
|
||||
organization,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
showSingleOrganization = false,
|
||||
}) => {
|
||||
const hasSingleSelectedOrganization =
|
||||
organizations.length <= 1 &&
|
||||
organizations.some((option) => option.id === organization.id);
|
||||
if (hasSingleSelectedOrganization && !showSingleOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The selected organization can fall outside the selectable options,
|
||||
// such as a deep link to an organization where servers are listable
|
||||
// but not creatable, so include it when disambiguating labels.
|
||||
const labelOrganizations = organizations.some(
|
||||
(option) => option.id === organization.id,
|
||||
)
|
||||
? organizations
|
||||
: [...organizations, organization];
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-72 flex-col gap-2", className)}>
|
||||
<Label htmlFor={id}>Organization</Label>
|
||||
<OrganizationAutocomplete
|
||||
id={id}
|
||||
ariaLabel={`Organization ${getOrganizationLabel(organization, labelOrganizations)}`}
|
||||
value={organization}
|
||||
onChange={(org) => {
|
||||
if (org) {
|
||||
onChange?.(org);
|
||||
}
|
||||
}}
|
||||
options={organizations}
|
||||
labelOrganizations={labelOrganizations}
|
||||
required
|
||||
disabled={disabled || !onChange || hasSingleSelectedOrganization}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Organization } from "#/api/typesGenerated";
|
||||
|
||||
export const orgSearchParam = "org";
|
||||
|
||||
export const selectOrganization = (
|
||||
organizations: readonly Organization[],
|
||||
name: string | null,
|
||||
): Organization => {
|
||||
const organization =
|
||||
organizations.find((org) => org.name === name) ??
|
||||
organizations.find((org) => org.is_default) ??
|
||||
organizations[0];
|
||||
if (!organization) {
|
||||
throw new Error("Expected at least one organization");
|
||||
}
|
||||
return organization;
|
||||
};
|
||||
|
||||
export const mcpServersPath = (organization: Organization) =>
|
||||
`/ai/settings/mcp-servers?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
|
||||
|
||||
export const addMCPServerPath = (organization: Organization) =>
|
||||
`/ai/settings/mcp-servers/add?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
|
||||
|
||||
export const updateMCPServerPath = (
|
||||
serverId: string,
|
||||
organization: Organization,
|
||||
) =>
|
||||
`/ai/settings/mcp-servers/${serverId}?${orgSearchParam}=${encodeURIComponent(organization.name)}`;
|
||||
+13
-1
@@ -465,7 +465,7 @@ const AISettingsUpdateMCPServerPage = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const AISettingsIndexRedirect = () => {
|
||||
export const AISettingsIndexRedirect = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
if (permissions.viewAnyAIProvider) {
|
||||
@@ -480,6 +480,18 @@ const AISettingsIndexRedirect = () => {
|
||||
return <Navigate to="/ai/settings/models" replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
permissions.viewAnyMCPServerConfigs ||
|
||||
permissions.updateAnyMCPServerConfig ||
|
||||
permissions.deleteAnyMCPServerConfig
|
||||
) {
|
||||
return <Navigate to="/ai/settings/mcp-servers" replace />;
|
||||
}
|
||||
|
||||
if (permissions.createAnyMCPServerConfig) {
|
||||
return <Navigate to="/ai/settings/mcp-servers/add" replace />;
|
||||
}
|
||||
|
||||
return <Navigate to="/ai/settings/providers" replace />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user