From 0c3b11ad1bf5abae9129f0598f0e1224b89289f6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:31:54 +0000 Subject: [PATCH 01/15] feat(coderd): add MCP ACL candidates endpoint --- coderd/apidoc/docs.go | 71 ++++++++ coderd/apidoc/swagger.json | 67 +++++++ coderd/coderd.go | 2 + coderd/mcp_acl.go | 169 ++++++++++++++++-- coderd/mcp_acl_available_test.go | 294 +++++++++++++++++++++++++++++++ codersdk/mcp.go | 20 +++ codersdk/templates.go | 2 +- site/src/api/typesGenerated.ts | 2 +- 8 files changed, 607 insertions(+), 20 deletions(-) create mode 100644 coderd/mcp_acl_available_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 84bdcd7f833..4855d9d4660 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2096,6 +2096,77 @@ const docTemplate = `{ } } }, + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "MCP" + ], + "summary": "Get available MCP server config ACL users and groups", + "operationId": "get-available-mcp-server-config-acl-users-and-groups", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User search query; free-text search also applies to groups", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "User after ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit for users and groups, if 0 returns all candidates", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "User page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ACLAvailable" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { "tags": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 03e9e31c92d..e511bd759f9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1877,6 +1877,73 @@ } } }, + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available": { + "get": { + "produces": ["application/json"], + "tags": ["MCP"], + "summary": "Get available MCP server config ACL users and groups", + "operationId": "get-available-mcp-server-config-acl-users-and-groups", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User search query; free-text search also applies to groups", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "User after ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit for users and groups, if 0 returns all candidates", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "User page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ACLAvailable" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { "tags": ["MCP"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 7199a8977d6..5a9ae063cea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1400,6 +1400,8 @@ func New(options *Options) *API { policy.ActionDelete)).Delete("/", api.deleteMCPServerConfig) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, policy.ActionShare)).Get("/acl", api.mcpServerConfigACL) + r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, + policy.ActionShare)).Get("/acl/available", api.mcpServerConfigACLAvailable) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, policy.ActionShare)).Patch("/acl", api.patchMCPServerConfigACL) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, diff --git a/coderd/mcp_acl.go b/coderd/mcp_acl.go index 0227c15cb13..ac9f768c7c8 100644 --- a/coderd/mcp_acl.go +++ b/coderd/mcp_acl.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "database/sql" "fmt" "maps" "net/http" @@ -18,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -58,6 +60,109 @@ func (api *API) mcpServerConfigACL(rw http.ResponseWriter, r *http.Request) { }) } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Get available MCP server config ACL users and groups +// @ID get-available-mcp-server-config-acl-users-and-groups +// @Security CoderSessionToken +// @Tags MCP +// @Produce json +// @Param organization path string true "Organization ID" format(uuid) +// @Param mcpserverconfig path string true "MCP server config ID" format(uuid) +// @Param q query string false "User search query; free-text search also applies to groups" +// @Param after_id query string false "User after ID" format(uuid) +// @Param limit query int false "Page limit for users and groups, if 0 returns all candidates" +// @Param offset query int false "User page offset" +// @Success 200 {object} codersdk.ACLAvailable +// @Router /api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available [get] +// @x-apidocgen {"skip": true} +func (api *API) mcpServerConfigACLAvailable(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + config := httpmw.MCPServerConfigParam(r) + if !api.Authorize(r, policy.ActionShare, config.RBACObject()) { + httpapi.ResourceNotFound(rw) + return + } + + userFilter, validations := searchquery.Users(r.URL.Query().Get("q")) + if len(validations) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user search query.", + Validations: validations, + }) + return + } + pagination, ok := ParsePagination(rw, r) + if !ok { + return + } + + //nolint:gocritic // The MCP server config share permission authorizes this + // bounded organization-scoped lookup even when the caller cannot browse the + // ordinary directories. + restrictedCtx := dbauthz.AsSystemRestricted(ctx) + members, err := api.Database.PaginatedOrganizationMembers(restrictedCtx, database.PaginatedOrganizationMembersParams{ + AfterID: pagination.AfterID, + OrganizationID: config.OrganizationID, + Search: userFilter.Search, + Name: userFilter.Name, + ExactUsername: userFilter.ExactUsername, + ExactEmail: userFilter.ExactEmail, + Status: userFilter.Status, + IsServiceAccount: userFilter.IsServiceAccount, + RbacRole: userFilter.RbacRole, + LastSeenBefore: userFilter.LastSeenBefore, + LastSeenAfter: userFilter.LastSeenAfter, + CreatedAfter: userFilter.CreatedAfter, + CreatedBefore: userFilter.CreatedBefore, + GithubComUserID: userFilter.GithubComUserID, + LoginType: userFilter.LoginType, + IncludeSystem: false, + // #nosec G115 - Pagination offsets are small and fit in int32. + OffsetOpt: int32(pagination.Offset), + // #nosec G115 - Pagination limits are small and fit in int32. + LimitOpt: int32(pagination.Limit), + }) + if err != nil { + httpapi.InternalServerError(rw, xerrors.Errorf("list MCP server config ACL users: %w", err)) + return + } + + groups, err := api.Database.GetGroups(restrictedCtx, database.GetGroupsParams{ + OrganizationID: config.OrganizationID, + Search: userFilter.Search, + // #nosec G115 - Pagination limits are small and fit in int32. + LimitOpt: int32(pagination.Limit), + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("list MCP server config ACL groups: %w", err)) + return + } + + groupIDs := make([]uuid.UUID, len(groups)) + for i, group := range groups { + groupIDs[i] = group.Group.ID + } + countByGroup, ok := api.mcpServerConfigACLGroupMemberCounts(restrictedCtx, rw, groupIDs) + if !ok { + return + } + + sdkUsers := make([]codersdk.ReducedUser, 0, len(members)) + for _, member := range members { + sdkUsers = append(sdkUsers, mcpServerConfigACLReducedUser(member)) + } + sdkGroups := make([]codersdk.Group, 0, len(groups)) + for _, group := range groups { + sdkGroups = append(sdkGroups, db2sdk.Group(group, nil, int(countByGroup[group.Group.ID]))) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{ + Users: sdkUsers, + Groups: sdkGroups, + }) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // // @Summary Update MCP server config ACL @@ -180,24 +285,14 @@ func (api *API) mcpServerConfigACLGroups(ctx context.Context, rw http.ResponseWr return nil, false } } - countByGroup := make(map[uuid.UUID]int64, len(groups)) - if len(groups) > 0 { - groupIDs := make([]uuid.UUID, 0, len(groups)) - for _, group := range groups { - groupIDs = append(groupIDs, group.Group.ID) - } - //nolint:gocritic // ACL managers may resolve group sizes after the share gate passes. - countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{ - GroupIds: groupIDs, - IncludeSystem: false, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return nil, false - } - for _, row := range countRows { - countByGroup[row.GroupID] = row.MemberCount - } + groupIDs := make([]uuid.UUID, 0, len(groups)) + for _, group := range groups { + groupIDs = append(groupIDs, group.Group.ID) + } + //nolint:gocritic // ACL managers may resolve group sizes after the share gate passes. + countByGroup, ok := api.mcpServerConfigACLGroupMemberCounts(dbauthz.AsSystemRestricted(ctx), rw, groupIDs) + if !ok { + return nil, false } result := make([]codersdk.MCPServerConfigGroup, 0, len(groups)) for _, group := range groups { @@ -209,6 +304,44 @@ func (api *API) mcpServerConfigACLGroups(ctx context.Context, rw http.ResponseWr return result, true } +func (api *API) mcpServerConfigACLGroupMemberCounts(ctx context.Context, rw http.ResponseWriter, groupIDs []uuid.UUID) (map[uuid.UUID]int64, bool) { + countByGroup := make(map[uuid.UUID]int64, len(groupIDs)) + if len(groupIDs) == 0 { + return countByGroup, true + } + + countRows, err := api.Database.GetGroupMembersCountByGroupIDs(ctx, database.GetGroupMembersCountByGroupIDsParams{ + GroupIds: groupIDs, + IncludeSystem: false, + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("count MCP server config ACL group members: %w", err)) + return nil, false + } + for _, row := range countRows { + countByGroup[row.GroupID] = row.MemberCount + } + return countByGroup, true +} + +func mcpServerConfigACLReducedUser(member database.PaginatedOrganizationMembersRow) codersdk.ReducedUser { + return codersdk.ReducedUser{ + MinimalUser: codersdk.MinimalUser{ + ID: member.OrganizationMember.UserID, + Username: member.Username, + Name: member.Name, + AvatarURL: member.AvatarURL, + }, + Email: member.Email, + CreatedAt: member.UserCreatedAt, + UpdatedAt: member.UserUpdatedAt, + LastSeenAt: member.LastSeenAt, + Status: codersdk.UserStatus(member.Status), + LoginType: codersdk.LoginType(member.LoginType), + IsServiceAccount: member.IsServiceAccount, + } +} + // canonicalMCPServerConfigACLRoles rekeys the request map by canonical // uuid.String() values so noncanonical spellings hit the same keys RBAC // reads, and rejects requests where two spellings collapse to one diff --git a/coderd/mcp_acl_available_test.go b/coderd/mcp_acl_available_test.go new file mode 100644 index 00000000000..f449f4efaef --- /dev/null +++ b/coderd/mcp_acl_available_test.go @@ -0,0 +1,294 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestMCPServerConfigACLAvailable(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db, _ := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), true) + + needleUser := dbgen.User(t, db, database.User{ + Username: "needle-user-" + testutil.GetRandomName(t), + Name: "Needle User", + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: firstUser.OrganizationID, + UserID: needleUser.ID, + }) + otherMember := dbgen.User(t, db, database.User{ + Username: "other-user-" + testutil.GetRandomName(t), + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: firstUser.OrganizationID, + UserID: otherMember.ID, + }) + + needleGroup := dbgen.Group(t, db, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "needle-group-" + testutil.GetRandomName(t), + DisplayName: "Needle Group", + }) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: needleGroup.ID, UserID: needleUser.ID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: needleGroup.ID, UserID: otherMember.ID}) + + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + foreignUser := dbgen.User(t, db, database.User{ + Username: "needle-foreign-user-" + testutil.GetRandomName(t), + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: otherOrganization.ID, + UserID: foreignUser.ID, + }) + foreignGroup := dbgen.Group(t, db, database.Group{ + OrganizationID: otherOrganization.ID, + Name: "needle-foreign-group-" + testutil.GetRandomName(t), + }) + + available, err := client.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + require.NoError(t, err) + usersByID := make(map[uuid.UUID]codersdk.ReducedUser, len(available.Users)) + for _, user := range available.Users { + usersByID[user.ID] = user + } + require.Equal(t, needleUser.Username, usersByID[needleUser.ID].Username) + require.Equal(t, needleUser.Name, usersByID[needleUser.ID].Name) + require.Equal(t, needleUser.Email, usersByID[needleUser.ID].Email) + require.Contains(t, usersByID, otherMember.ID) + require.NotContains(t, usersByID, database.PrebuildsSystemUserID) + require.NotContains(t, usersByID, foreignUser.ID) + + groupsByID := make(map[uuid.UUID]codersdk.Group, len(available.Groups)) + for _, group := range available.Groups { + groupsByID[group.ID] = group + require.Empty(t, group.Members) + } + require.Equal(t, needleGroup.Name, groupsByID[needleGroup.ID].Name) + require.Equal(t, needleGroup.DisplayName, groupsByID[needleGroup.ID].DisplayName) + require.Equal(t, 2, groupsByID[needleGroup.ID].TotalMemberCount) + require.Equal(t, 3, groupsByID[firstUser.OrganizationID].TotalMemberCount) + require.NotContains(t, groupsByID, foreignGroup.ID) + + filtered, err := client.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{ + SearchQuery: "needle", + Pagination: codersdk.Pagination{Limit: 1}, + }) + require.NoError(t, err) + require.Len(t, filtered.Users, 1) + require.Equal(t, needleUser.ID, filtered.Users[0].ID) + require.Len(t, filtered.Groups, 1) + require.Equal(t, needleGroup.ID, filtered.Groups[0].ID) + require.Equal(t, 2, filtered.Groups[0].TotalMemberCount) + require.Empty(t, filtered.Groups[0].Members) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err = memberClient.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) + + _, err = client.MCPServerConfigACLAvailable(ctx, otherOrganization.ID, config.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) +} + +func TestMCPServerConfigACLWorkspaceSharingModes(t *testing.T) { + t.Parallel() + + modes := []database.ShareableWorkspaceOwners{ + database.ShareableWorkspaceOwnersNone, + database.ShareableWorkspaceOwnersServiceAccounts, + database.ShareableWorkspaceOwnersEveryone, + } + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, rawDB, api := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + systemCtx := dbauthz.AsSystemRestricted(ctx) + + organization, err := rawDB.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: firstUser.OrganizationID, + ShareableWorkspaceOwners: mode, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + _, _, err = rolestore.ReconcileSystemRole(systemCtx, api, database.CustomRole{ + Name: rbac.RoleOrgMember(), + OrganizationID: uuid.NullUUID{ + UUID: firstUser.OrganizationID, + Valid: true, + }, + }, organization) + require.NoError(t, err) + + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), true) + preexistingGroup := dbgen.Group(t, rawDB, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "preexisting-" + testutil.GetRandomName(t), + }) + candidateGroup := dbgen.Group(t, rawDB, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "candidate-" + testutil.GetRandomName(t), + DisplayName: "Candidate Group", + }) + err = client.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{ + GroupRoles: map[string]codersdk.MCPServerConfigRole{ + preexistingGroup.ID.String(): codersdk.MCPServerConfigRoleRead, + }, + }) + require.NoError(t, err) + + sharerClient, sharer := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, candidateUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + dbgen.GroupMember(t, rawDB, database.GroupMemberTable{ + GroupID: candidateGroup.ID, + UserID: candidateUser.ID, + }) + + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "MCP Server Config Sharer", + OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionShare, + }, + }, + }) + require.NoError(t, err) + _, err = client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, sharer.ID.String(), codersdk.UpdateRoles{ + Roles: []string{role.Name}, + }) + require.NoError(t, err) + + initialACL, err := sharerClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Empty(t, initialACL.Users) + require.Contains(t, mcpServerConfigACLGroupRoles(initialACL), firstUser.OrganizationID) + preexistingACLGroup := mcpServerConfigACLGroupByID(t, initialACL, preexistingGroup.ID) + require.Equal(t, preexistingGroup.Name, preexistingACLGroup.Name) + require.Empty(t, preexistingACLGroup.Members) + + available, err := sharerClient.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + require.NoError(t, err) + availableUser := mcpServerConfigACLAvailableUserByID(t, available, candidateUser.ID) + require.Equal(t, candidateUser.Username, availableUser.Username) + availableGroup := mcpServerConfigACLAvailableGroupByID(t, available, candidateGroup.ID) + require.Equal(t, candidateGroup.DisplayName, availableGroup.DisplayName) + require.Equal(t, 1, availableGroup.TotalMemberCount) + require.Empty(t, availableGroup.Members) + + err = sharerClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{ + UserRoles: map[string]codersdk.MCPServerConfigRole{ + candidateUser.ID.String(): codersdk.MCPServerConfigRoleRead, + }, + GroupRoles: map[string]codersdk.MCPServerConfigRole{ + candidateGroup.ID.String(): codersdk.MCPServerConfigRoleRead, + preexistingGroup.ID.String(): codersdk.MCPServerConfigRoleDeleted, + }, + }) + require.NoError(t, err) + + updatedACL, err := sharerClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Equal(t, map[uuid.UUID]codersdk.MCPServerConfigRole{ + candidateUser.ID: codersdk.MCPServerConfigRoleRead, + }, mcpServerConfigACLUserRoles(updatedACL)) + require.Equal(t, map[uuid.UUID]codersdk.MCPServerConfigRole{ + firstUser.OrganizationID: codersdk.MCPServerConfigRoleRead, + candidateGroup.ID: codersdk.MCPServerConfigRoleRead, + }, mcpServerConfigACLGroupRoles(updatedACL)) + }) + } +} + +func newMCPClientWithDatabase(t testing.TB) (client *codersdk.Client, rawDB database.Store, apiDB database.Store) { + t.Helper() + + db, pubsub := dbtestutil.NewDB(t) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + return client, db, api.Database +} + +func mcpServerConfigACLUserRoles(acl codersdk.MCPServerConfigACL) map[uuid.UUID]codersdk.MCPServerConfigRole { + roles := make(map[uuid.UUID]codersdk.MCPServerConfigRole, len(acl.Users)) + for _, user := range acl.Users { + roles[user.ID] = user.Role + } + return roles +} + +func mcpServerConfigACLGroupRoles(acl codersdk.MCPServerConfigACL) map[uuid.UUID]codersdk.MCPServerConfigRole { + roles := make(map[uuid.UUID]codersdk.MCPServerConfigRole, len(acl.Groups)) + for _, group := range acl.Groups { + roles[group.ID] = group.Role + } + return roles +} + +func mcpServerConfigACLGroupByID(t testing.TB, acl codersdk.MCPServerConfigACL, groupID uuid.UUID) codersdk.MCPServerConfigGroup { + t.Helper() + for _, group := range acl.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "MCP server config ACL group not found", "group_id=%s", groupID) + return codersdk.MCPServerConfigGroup{} +} + +func mcpServerConfigACLAvailableUserByID(t testing.TB, available codersdk.ACLAvailable, userID uuid.UUID) codersdk.ReducedUser { + t.Helper() + for _, user := range available.Users { + if user.ID == userID { + return user + } + } + require.FailNow(t, "MCP server config ACL available user not found", "user_id=%s", userID) + return codersdk.ReducedUser{} +} + +func mcpServerConfigACLAvailableGroupByID(t testing.TB, available codersdk.ACLAvailable, groupID uuid.UUID) codersdk.Group { + t.Helper() + for _, group := range available.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "MCP server config ACL available group not found", "group_id=%s", groupID) + return codersdk.Group{} +} diff --git a/codersdk/mcp.go b/codersdk/mcp.go index edb70ca9971..f965e00945f 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -252,6 +252,26 @@ func (c *Client) MCPServerConfigACL(ctx context.Context, organizationID, id uuid return acl, ReadBodyAsJSON(res, &acl) } +// MCPServerConfigACLAvailable returns available users and groups that can be +// assigned to an MCP server config ACL. +func (c *Client) MCPServerConfigACLAvailable(ctx context.Context, organizationID, id uuid.UUID, req UsersRequest) (ACLAvailable, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s/acl/available", organizationID, id), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return ACLAvailable{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ACLAvailable{}, ReadBodyAsError(res) + } + var available ACLAvailable + return available, ReadBodyAsJSON(res, &available) +} + // UpdateMCPServerConfigACL applies a sparse ACL update to an MCP server // config. func (c *Client) UpdateMCPServerConfigACL(ctx context.Context, organizationID, id uuid.UUID, req UpdateMCPServerConfigACLRequest) error { diff --git a/codersdk/templates.go b/codersdk/templates.go index 4c382b6ae46..7b256f38b40 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -212,7 +212,7 @@ type UpdateTemplateACL struct { GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:":admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"` } -// ACLAvailable is a list of users and groups that can be added to a template +// ACLAvailable is a list of users and groups that can be added to a resource // ACL. type ACLAvailable struct { Users []ReducedUser `json:"users"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8320f2cb68..61ebdf95c91 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2,7 +2,7 @@ // From codersdk/templates.go /** - * ACLAvailable is a list of users and groups that can be added to a template + * ACLAvailable is a list of users and groups that can be added to a resource * ACL. */ export interface ACLAvailable { From fc5da1a753008a4b452f39a68e62e759426e7db8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:57:22 +0000 Subject: [PATCH 02/15] feat(site): add MCP server ACL sharing plumbing --- site/src/api/api.test.ts | 40 +++++++ site/src/api/api.ts | 34 ++++++ site/src/api/queries/chats.test.ts | 104 +++++++++++++++++- site/src/api/queries/chats.ts | 70 ++++++++++++ .../modules/management/AISettingsSidebar.tsx | 14 +++ .../management/AISettingsSidebarView.tsx | 8 +- site/src/modules/permissions/organizations.ts | 7 ++ .../MCPServersPage/MCPServersPage.tsx | 13 ++- .../UpdateMCPServerPage.tsx | 12 +- site/src/testHelpers/entities.ts | 15 +++ site/src/testHelpers/handlers.ts | 14 +++ 11 files changed, 321 insertions(+), 10 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index d36eec59f0c..aba73fc961e 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,4 +1,6 @@ import { + MockMCPServerConfigACL, + MockMCPServerConfigACLAvailable, MockProvisionerJob, MockStoppedWorkspace, MockTemplate, @@ -526,6 +528,44 @@ describe("api.ts", () => { expect(axiosInstance.get).toHaveBeenCalledWith(aclPath); expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, acl); }); + + it("uses organization-nested MCP server ACL paths", async () => { + const serverId = "server/id"; + const update: TypesGen.UpdateMCPServerConfigACLRequest = { + user_roles: { "user-1": "read" }, + }; + vi.spyOn(axiosInstance, "get") + .mockResolvedValueOnce({ data: MockMCPServerConfigACL }) + .mockResolvedValueOnce({ data: MockMCPServerConfigACLAvailable }); + vi.spyOn(axiosInstance, "patch").mockResolvedValueOnce({}); + + await expect( + API.experimental.getMCPServerConfigACL(organizationId, serverId), + ).resolves.toStrictEqual(MockMCPServerConfigACL); + await expect( + API.experimental.getMCPServerConfigACLAvailable( + organizationId, + serverId, + { q: "alice@example.com", limit: 25 }, + ), + ).resolves.toStrictEqual(MockMCPServerConfigACLAvailable); + await expect( + API.experimental.updateMCPServerConfigACL( + organizationId, + serverId, + update, + ), + ).resolves.toBeUndefined(); + + const aclPath = + "/api/experimental/organizations/organization%2Fid/mcp-servers/server%2Fid/acl"; + expect(axiosInstance.get).toHaveBeenNthCalledWith(1, aclPath); + expect(axiosInstance.get).toHaveBeenNthCalledWith( + 2, + `${aclPath}/available?q=alice%40example.com&limit=25`, + ); + expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, update); + }); }); describe("user secrets endpoints", () => { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 03b2a0e7c63..436465dd4e4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -368,6 +368,8 @@ const mcpServerConfigsPath = (organization: string) => `/api/experimental/organizations/${encodeURIComponent(organization)}/mcp-servers`; const mcpServerConfigPath = (organization: string, id: string) => `${mcpServerConfigsPath(organization)}/${encodeURIComponent(id)}`; +const mcpServerConfigACLPath = (organization: string, id: string) => + `${mcpServerConfigPath(organization, id)}/acl`; export const mcpServerOAuth2ConnectPath = (organization: string, id: string) => `${mcpServerConfigPath(organization, id)}/oauth2/connect`; const mcpServerOAuth2DisconnectPath = (id: string) => @@ -3986,6 +3988,38 @@ class ExperimentalApiMethods { return response.data; }; + getMCPServerConfigACL = async ( + organization: string, + id: string, + ): Promise => { + const response = await this.axios.get( + mcpServerConfigACLPath(organization, id), + ); + return response.data; + }; + + getMCPServerConfigACLAvailable = async ( + organization: string, + id: string, + options: TypesGen.UsersRequest, + ): Promise => { + const response = await this.axios.get( + getURLWithSearchParams( + `${mcpServerConfigACLPath(organization, id)}/available`, + options, + ), + ); + return response.data; + }; + + updateMCPServerConfigACL = async ( + organization: string, + id: string, + req: TypesGen.UpdateMCPServerConfigACLRequest, + ): Promise => { + await this.axios.patch(mcpServerConfigACLPath(organization, id), req); + }; + createMCPServerConfig = async ( organization: string, req: TypesGen.CreateMCPServerConfigRequest, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 5bc443aef8d..f893a9dcdeb 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -7,9 +7,16 @@ import { ERROR_STATUSES, SUCCESS_STATUSES, } from "#/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils"; -import { MockChatMessage } from "#/testHelpers/chatEntities"; +import { + MockChatMessage, + MockMCPServerConfig, +} from "#/testHelpers/chatEntities"; import { MockChatModel } from "#/testHelpers/chatModels"; import { createDeferred } from "#/testHelpers/deferred"; +import { + MockMCPServerConfigACL, + MockMCPServerConfigACLAvailable, +} from "#/testHelpers/entities"; import { buildOptimisticEditedMessage } from "./chatMessageEdits"; import { addChildToParentInCache, @@ -63,6 +70,12 @@ import { invalidateChatPrompts, invalidateChatSearches, invalidateChatsByWorkspace, + mcpServerConfigACL, + mcpServerConfigACLAvailable, + mcpServerConfigACLAvailableKey, + mcpServerConfigACLKey, + mcpServerConfigKey, + mcpServerConfigsKey, mergeWatchedChatIntoCaches, mergeWatchedChatSummary, openChat, @@ -94,6 +107,7 @@ import { updateChatWorkspace, updateChildInParentCache, updateInfiniteChatsCache, + updateMCPServerConfigACL, upsertChatMessages, } from "./chats"; @@ -118,6 +132,9 @@ vi.mock("#/api/api", () => ({ getChatModel: vi.fn(), getChatModelACL: vi.fn(), updateChatModelACL: vi.fn(), + getMCPServerConfigACL: vi.fn(), + getMCPServerConfigACLAvailable: vi.fn(), + updateMCPServerConfigACL: vi.fn(), updateChatModel: vi.fn(), deleteChatModel: vi.fn(), }, @@ -337,6 +354,91 @@ describe("chat model query factories", () => { }); }); +describe("MCP server ACL query factories", () => { + const organization = "organization-1"; + const otherOrganization = "organization-2"; + const serverId = MockMCPServerConfig.id; + + it("scopes ACL candidates by organization, server, and options", async () => { + const options = { q: "alice@example.com", limit: 25 }; + const otherOptions = { q: "bob@example.com", limit: 25 }; + vi.mocked( + API.experimental.getMCPServerConfigACLAvailable, + ).mockResolvedValue(MockMCPServerConfigACLAvailable); + + const query = mcpServerConfigACLAvailable(organization, serverId, options); + + expect(query.queryKey).toEqual( + mcpServerConfigACLAvailableKey(organization, serverId, options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(otherOrganization, serverId, options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(organization, "other-server", options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(organization, serverId, otherOptions), + ); + await expect(query.queryFn()).resolves.toEqual( + MockMCPServerConfigACLAvailable, + ); + expect( + API.experimental.getMCPServerConfigACLAvailable, + ).toHaveBeenCalledWith(organization, serverId, options); + }); + + it("gets and sparsely updates an organization-scoped ACL", async () => { + const req = { user_roles: { "user-1": "read" as const } }; + vi.mocked(API.experimental.getMCPServerConfigACL).mockResolvedValue( + MockMCPServerConfigACL, + ); + vi.mocked(API.experimental.updateMCPServerConfigACL).mockResolvedValue(); + + const query = mcpServerConfigACL(organization, serverId); + expect(query.queryKey).toEqual( + mcpServerConfigACLKey(organization, serverId), + ); + await expect(query.queryFn()).resolves.toEqual(MockMCPServerConfigACL); + expect(API.experimental.getMCPServerConfigACL).toHaveBeenCalledWith( + organization, + serverId, + ); + + const queryClient = createTestQueryClient(); + const keys = [ + mcpServerConfigACLKey(organization, serverId), + mcpServerConfigKey(organization, serverId), + mcpServerConfigsKey(organization), + ["authorization", "mcp-servers"], + ["organizations", [organization], "permissions"], + ] as const; + const unaffectedKeys = [ + ["organizations"], + ["organizations", [otherOrganization], "permissions"], + ] as const; + for (const key of [...keys, ...unaffectedKeys]) { + queryClient.setQueryData(key, {}); + } + const variables = { organization, id: serverId, req }; + const mutation = updateMCPServerConfigACL(queryClient); + + await expect(mutation.mutationFn(variables)).resolves.toBeUndefined(); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + organization, + serverId, + req, + ); + await mutation.onSuccess(undefined, variables); + for (const key of keys) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + } + for (const key of unaffectedKeys) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false); + } + }); +}); + describe("advisor config query factories", () => { it("builds the advisor config query and delegates to the API", async () => { const advisorConfig: TypesGen.AdvisorConfig = { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index ab8a21d0b2b..6b67c79b2c1 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2449,6 +2449,34 @@ export const mcpServerConfig = (organization: string, id: string) => ({ API.experimental.getMCPServerConfig(organization, id), }); +export const mcpServerConfigACLKey = (organization: string, id: string) => + [...mcpServerConfigKey(organization, id), "acl"] as const; + +export const mcpServerConfigACL = (organization: string, id: string) => ({ + queryKey: mcpServerConfigACLKey(organization, id), + queryFn: (): Promise => + API.experimental.getMCPServerConfigACL(organization, id), + enabled: organization !== "" && id !== "", +}); + +export const mcpServerConfigACLAvailableKey = ( + organization: string, + id: string, + options: TypesGen.UsersRequest, +) => + [...mcpServerConfigACLKey(organization, id), "available", options] as const; + +export const mcpServerConfigACLAvailable = ( + organization: string, + id: string, + options: TypesGen.UsersRequest, +) => ({ + queryKey: mcpServerConfigACLAvailableKey(organization, id, options), + queryFn: (): Promise => + API.experimental.getMCPServerConfigACLAvailable(organization, id, options), + enabled: organization !== "" && id !== "", +}); + const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { await queryClient.invalidateQueries({ queryKey: mcpServersKey }); }; @@ -2491,6 +2519,48 @@ export const deleteMCPServerConfig = ( }, }); +type UpdateMCPServerConfigACLMutationArgs = { + organization: string; + id: string; + req: TypesGen.UpdateMCPServerConfigACLRequest; +}; + +export const updateMCPServerConfigACL = (queryClient: QueryClient) => ({ + mutationFn: ({ + organization, + id, + req, + }: UpdateMCPServerConfigACLMutationArgs) => + API.experimental.updateMCPServerConfigACL(organization, id, req), + onSuccess: async ( + _data: unknown, + variables: UpdateMCPServerConfigACLMutationArgs, + ) => { + const { organization, id } = variables; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: mcpServerConfigACLKey(organization, id), + exact: true, + }), + queryClient.invalidateQueries({ + queryKey: mcpServerConfigKey(organization, id), + exact: true, + }), + queryClient.invalidateQueries({ + queryKey: mcpServerConfigsKey(organization), + exact: true, + }), + queryClient.invalidateQueries({ queryKey: authorizationKey }), + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => + queryKey[0] === "organizations" && + queryKey[2] === "permissions" && + (queryKey[1] as readonly string[]).includes(organization), + }), + ]); + }, +}); + export const disconnectMCPServerOAuth2 = (queryClient: QueryClient) => ({ mutationFn: (id: string) => API.experimental.disconnectMCPServerOAuth2(id), onSuccess: async () => { diff --git a/site/src/modules/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx index 30228b3667f..50887e14575 100644 --- a/site/src/modules/management/AISettingsSidebar.tsx +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -1,4 +1,6 @@ import type { FC } from "react"; +import { useQuery } from "react-query"; +import { organizationsPermissions } from "#/api/queries/organizations"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; @@ -11,6 +13,17 @@ export const AISettingsSidebar: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const accessibleOrgsQuery = useAccessibleModelOrganizations(organizations); + const organizationPermissionsQuery = useQuery({ + ...organizationsPermissions( + organizations.map((organization) => organization.id), + ), + enabled: !permissions.editDeploymentConfig, + }); + const canShareOrganizationMCPServers = organizations.some( + (organization) => + organizationPermissionsQuery.data?.[organization.id] + ?.shareMCPServerConfig, + ); return ( { canAccessOrganizationModels={ (accessibleOrgsQuery.organizations.length ?? 0) > 0 } + canShareOrganizationMCPServers={canShareOrganizationMCPServers} /> ); }; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 2a636f79734..cccec41148e 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -14,6 +14,7 @@ interface AISettingsSidebarViewProps { /** Site-wide permissions. */ permissions: Permissions; canAccessOrganizationModels?: boolean; + canShareOrganizationMCPServers?: boolean; } const SubNavItem: FC<{ href: string; children?: ReactNode }> = ({ @@ -59,6 +60,7 @@ const ModelsSidebarNavItem: FC = () => { const AISettingsSidebarView: FC = ({ permissions, canAccessOrganizationModels = false, + canShareOrganizationMCPServers = false, }) => { return ( @@ -101,13 +103,15 @@ const AISettingsSidebarView: FC = ({ (permissions.viewAnyMCPServerConfigs || permissions.createAnyMCPServerConfig || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig) && ( + permissions.deleteAnyMCPServerConfig || + canShareOrganizationMCPServers) && (
}, action: "delete", }, + shareMCPServerConfig: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "share", + }, viewChatModelConfigs: { object: { resource_type: "chat_model_config", diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index b7fb6fe0f58..5c5b8eb1fed 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -30,7 +30,8 @@ const MCPServersPage: FC = () => { return Boolean( organizationPermissions?.viewMCPServerConfigs || organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); }); const creatableOrganizations = permissions.editDeploymentConfig @@ -59,13 +60,15 @@ const MCPServersPage: FC = () => { Boolean( organizationPermissions?.viewMCPServerConfigs || organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); const canOpenServer = permissions.editDeploymentConfig || Boolean( organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); const serversQuery = useQuery({ ...mcpServerConfigs(organization?.id ?? ""), @@ -81,7 +84,9 @@ const MCPServersPage: FC = () => { permissions.editDeploymentConfig || permissions.viewAnyMCPServerConfigs || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig + permissions.deleteAnyMCPServerConfig || + organizationPermissionsQuery.data === undefined || + authorizedOrganizations.length > 0 } > Codestin Search App diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index e26bd6dc37c..144ee93c89d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -45,7 +45,8 @@ const UpdateMCPServerPage: FC = () => { organizationPermissionsQuery.data?.[organization.id]; return Boolean( organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); }); const requestedOrganizationName = searchParams.get(orgSearchParam); @@ -66,7 +67,10 @@ const UpdateMCPServerPage: FC = () => { const canDelete = permissions.editDeploymentConfig || Boolean(organizationPermissions?.deleteMCPServerConfig); - const canManage = canUpdate || canDelete; + const canShare = + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.shareMCPServerConfig); + const canManage = canUpdate || canDelete || canShare; const queryClient = useQueryClient(); const navigate = useNavigate(); const serverQuery = useQuery({ @@ -100,7 +104,9 @@ const UpdateMCPServerPage: FC = () => { isFeatureVisible={ permissions.editDeploymentConfig || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig + permissions.deleteAnyMCPServerConfig || + organizationPermissionsQuery.data === undefined || + manageableOrganizations.length > 0 } > {organizationPermissionsQuery.isLoadingError ? ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 55800026e46..ef3c46fc6b5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3319,6 +3319,19 @@ export const MockEveryoneGroup: TypesGen.Group = { total_member_count: 0, }; +export const MockMCPServerConfigACL: TypesGen.MCPServerConfigACL = { + users: [{ ...MockUserMember, role: "read" }], + groups: [ + { ...MockGroup, role: "read" }, + { ...MockEveryoneGroup, role: "read" }, + ], +}; + +export const MockMCPServerConfigACLAvailable: TypesGen.ACLAvailable = { + users: [MockUserOwner, MockUserMember], + groups: [MockGroup, MockGroup2, MockEveryoneGroup], +}; + export const MockTemplateACL: TypesGen.TemplateACL = { group: [ { ...MockEveryoneGroup, role: "use" }, @@ -3466,6 +3479,7 @@ export const MockOrganizationPermissions: OrganizationPermissions = { createMCPServerConfig: true, updateMCPServerConfig: true, deleteMCPServerConfig: true, + shareMCPServerConfig: true, viewChatModelConfigs: true, createChatModelConfigs: true, editChatModelConfigs: true, @@ -3493,6 +3507,7 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { createMCPServerConfig: false, updateMCPServerConfig: false, deleteMCPServerConfig: false, + shareMCPServerConfig: false, viewChatModelConfigs: false, createChatModelConfigs: false, editChatModelConfigs: false, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index a615681ff1b..a8113f6135a 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -82,6 +82,20 @@ export const handlers = [ }, ), + // MCP servers + http.get( + "/api/experimental/organizations/:organizationId/mcp-servers/:serverId/acl/available", + () => HttpResponse.json(M.MockMCPServerConfigACLAvailable), + ), + http.get( + "/api/experimental/organizations/:organizationId/mcp-servers/:serverId/acl", + () => HttpResponse.json(M.MockMCPServerConfigACL), + ), + http.patch( + "/api/experimental/organizations/:organizationId/mcp-servers/:serverId/acl", + () => new HttpResponse(null, { status: 204 }), + ), + // templates http.get("/api/v2/templates", () => { return HttpResponse.json([M.MockTemplate]); From 661b361c9072df481326d56e74549559d748d85c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:03:12 +0000 Subject: [PATCH 03/15] feat(site): add MCP server sharing dialog --- .../MCPServerPrincipalAutocomplete.tsx | 109 +++++ .../MCPServerSharingDialog.stories.tsx | 460 ++++++++++++++++++ .../components/MCPServerSharingDialog.tsx | 373 ++++++++++++++ 3 files changed, 942 insertions(+) create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx new file mode 100644 index 00000000000..59f5cb0c15c --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx @@ -0,0 +1,109 @@ +import { CheckIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { mcpServerConfigACLAvailable } from "#/api/queries/chats"; +import type { Group, ReducedUser } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { getGroupSubtitle, isGroup } from "#/modules/groups"; +import { prepareQuery } from "#/utils/filters"; + +export type MCPServerPrincipalAutocompleteValue = ReducedUser | Group | null; +type AutocompleteOption = Exclude; + +type MCPServerPrincipalAutocompleteProps = { + value: MCPServerPrincipalAutocompleteValue; + onChange: (value: MCPServerPrincipalAutocompleteValue) => void; + organizationId: string; + serverId: string; + excludedPrincipalIds: readonly string[]; + className?: string; +}; + +export const MCPServerPrincipalAutocomplete: FC< + MCPServerPrincipalAutocompleteProps +> = ({ + value, + onChange, + organizationId, + serverId, + excludedPrincipalIds, + className, +}) => { + const [inputValue, setInputValue] = useState(""); + const [open, setOpen] = useState(false); + const autocompleteId = useId(); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setInputValue(""); + } + }; + + const aclAvailableQuery = useQuery({ + ...mcpServerConfigACLAvailable(organizationId, serverId, { + q: prepareQuery(inputValue), + limit: 25, + }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const options: AutocompleteOption[] = aclAvailableQuery.data + ? [ + ...aclAvailableQuery.data.groups, + ...aclAvailableQuery.data.users, + ].filter((principal) => !excludedPrincipalIds.includes(principal.id)) + : []; + + return ( +
+ option.id} + getOptionLabel={(option) => + isGroup(option) ? option.display_name || option.name : option.email + } + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
+ + {isSelected && } +
+ )} + open={open} + onOpenChange={handleOpenChange} + inputValue={inputValue} + onInputChange={setInputValue} + loading={aclAvailableQuery.isFetching} + placeholder="Search for user or group" + noOptionsText={ + aclAvailableQuery.error + ? "Unable to load users or groups" + : "No users or groups found" + } + className={className} + id={autocompleteId} + /> + {aclAvailableQuery.error && ( + + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx new file mode 100644 index 00000000000..e364488abb9 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx @@ -0,0 +1,460 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + MockDefaultOrganization, + MockEveryoneGroup, + MockGroup, + MockGroup2, + MockMCPServerConfigACLAvailable, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; +import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; +import { MockCoderMCPServer } from "../testFixtures"; +import { MCPServerSharingDialog } from "./MCPServerSharingDialog"; + +type MockACL = TypesGen.MCPServerConfigACL; + +const emptyACL: MockACL = { users: [], groups: [] }; +const populatedACL: MockACL = { + users: [{ ...MockUserMember, role: "read" }], + groups: [{ ...MockGroup, role: "read" }], +}; +const everyoneACL: MockACL = { + users: [], + groups: [{ ...MockEveryoneGroup, role: "read" }], +}; +const refreshedACL: MockACL = { + users: [{ ...MockUserOwner, role: "read" }], + groups: [{ ...MockGroup2, role: "read" }], +}; + +const mockLegacyPrincipalRequests = () => { + spyOn(API, "getOrganizationPaginatedMembers").mockRejectedValue( + new Error("Legacy organization member discovery must not be called"), + ); + spyOn(API, "getGroupsByOrganization").mockRejectedValue( + new Error("Legacy organization group discovery must not be called"), + ); +}; + +const mockRequests = ({ + acl = emptyACL, + aclError, + aclPending = false, + availableError, + updateError, +}: { + acl?: MockACL; + aclError?: Error; + aclPending?: boolean; + availableError?: Error; + updateError?: Error; +} = {}) => { + mockLegacyPrincipalRequests(); + if (aclPending) { + spyOn(API.experimental, "getMCPServerConfigACL").mockReturnValue( + new Promise(() => undefined), + ); + } else if (aclError) { + spyOn(API.experimental, "getMCPServerConfigACL").mockRejectedValue( + aclError, + ); + } else { + spyOn(API.experimental, "getMCPServerConfigACL").mockResolvedValue(acl); + } + if (availableError) { + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockRejectedValue( + availableError, + ); + } else { + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockResolvedValue( + MockMCPServerConfigACLAvailable, + ); + } + if (updateError) { + spyOn(API.experimental, "updateMCPServerConfigACL").mockRejectedValue( + updateError, + ); + } else { + spyOn(API.experimental, "updateMCPServerConfigACL").mockResolvedValue(); + } +}; + +const addAutocompleteOption = async ( + body: ReturnType, + query: string, + option: string | RegExp, +) => { + await userEvent.click( + await body.findByRole("button", { name: "Search for user or group" }), + ); + await userEvent.type( + body.getByPlaceholderText("Search for user or group"), + query, + ); + await userEvent.click(await body.findByRole("option", { name: option })); + await userEvent.click(body.getByRole("button", { name: "Add member" })); +}; + +let currentServerACL = populatedACL; + +const ReopenableSharingDialog = () => { + const [open, setOpen] = useState(true); + return ( + <> + + + + ); +}; + +const meta: Meta = { + title: "pages/AISettingsPage/MCPServersPage/MCPServerSharingDialog", + component: MCPServerSharingDialog, + decorators: [withDashboardProvider, withToaster], + args: { + open: true, + onOpenChange: fn(), + organizationId: MockDefaultOrganization.id, + serverId: MockCoderMCPServer.id, + serverName: MockCoderMCPServer.display_name, + }, +}; + +export default meta; +type Story = StoryObj; + +export const EmptyACL: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByText("No shared members or groups yet"), + ).toBeInTheDocument(); + expect(body.getByRole("button", { name: "Save sharing" })).toBeDisabled(); + expect(API.getOrganizationPaginatedMembers).not.toHaveBeenCalled(); + expect(API.getGroupsByOrganization).not.toHaveBeenCalled(); + }, +}; + +export const Loading: Story = { + beforeEach: () => mockRequests({ aclPending: true }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect(await body.findByRole("status")).toHaveTextContent( + "Loading server sharing", + ); + }, +}; + +export const InitialACLFailureIsBlocking: Story = { + beforeEach: () => mockRequests({ aclError: new Error("Unable to load ACL") }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect(await body.findByText("Unable to load ACL")).toBeInTheDocument(); + expect(body.getByRole("button", { name: "Save sharing" })).toBeDisabled(); + expect( + body.queryByRole("button", { name: "Search for user or group" }), + ).not.toBeInTheDocument(); + expect( + body.queryByRole("table", { name: "Shared server members and groups" }), + ).not.toBeInTheDocument(); + }, +}; + +export const HydratedPrincipalsRenderWithoutIDs: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("row", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).toBeInTheDocument(); + expect(body.queryByText(MockUserMember.id)).not.toBeInTheDocument(); + expect(body.queryByText(MockGroup.id)).not.toBeInTheDocument(); + expect(API.getOrganizationPaginatedMembers).not.toHaveBeenCalled(); + expect(API.getGroupsByOrganization).not.toHaveBeenCalled(); + }, +}; + +export const EveryoneGroup: Story = { + beforeEach: () => mockRequests({ acl: everyoneACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const everyoneRow = await body.findByRole("row", { name: /Everyone/i }); + expect(everyoneRow).toHaveTextContent("Everyone"); + expect(everyoneRow).toHaveTextContent("All users"); + }, +}; + +export const AddUser: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await addAutocompleteOption( + body, + MockUserMember.email, + new RegExp(MockUserMember.email, "i"), + ); + + expect( + body.getByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeVisible(); + await userEvent.click(body.getByRole("button", { name: "Save sharing" })); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledTimes( + 1, + ), + ); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { user_roles: { [MockUserMember.id]: "read" } }, + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const AddGroup: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await addAutocompleteOption( + body, + MockGroup2.name, + new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + ); + + expect( + body.getByRole("row", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeVisible(); + await userEvent.click(body.getByRole("button", { name: "Save sharing" })); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledTimes( + 1, + ), + ); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { group_roles: { [MockGroup2.id]: "read" } }, + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const SelectedPrincipalsExcludedFromAutocomplete: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: "Search for user or group", + }), + ); + + expect( + body.queryByRole("option", { + name: new RegExp(MockUserMember.email, "i"), + }), + ).not.toBeInTheDocument(); + expect( + body.queryByRole("option", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).not.toBeInTheDocument(); + expect( + await body.findByRole("option", { + name: new RegExp(MockUserOwner.email, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("option", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeInTheDocument(); + }, +}; + +export const SaveRemovalsAsSparseDelta: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click( + body.getByRole("button", { + name: `Remove ${MockUserMember.username}`, + }), + ); + await userEvent.click(body.getByRole("button", { name: "Save sharing" })); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { + user_roles: { [MockUserMember.id]: "" }, + group_roles: { [MockGroup.id]: "" }, + }, + ), + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const CandidateDiscoveryFailureKeepsACLUsable: Story = { + beforeEach: () => + mockRequests({ + acl: populatedACL, + availableError: new Error("Unable to discover principals"), + }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const userRow = await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }); + await userEvent.click( + body.getByRole("button", { name: "Search for user or group" }), + ); + expect(await body.findByRole("alert")).toHaveTextContent( + "Unable to discover principals", + ); + expect(userRow).toBeInTheDocument(); + await userEvent.click( + body.getByRole("button", { name: `Remove ${MockGroup.display_name}` }), + ); + await userEvent.click(body.getByRole("button", { name: "Save sharing" })); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { group_roles: { [MockGroup.id]: "" } }, + ), + ); + }, +}; + +export const ReopenUsesFreshACL: Story = { + render: () => , + beforeEach: () => { + mockLegacyPrincipalRequests(); + currentServerACL = populatedACL; + spyOn(API.experimental, "getMCPServerConfigACL").mockImplementation( + async () => currentServerACL, + ); + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockResolvedValue( + MockMCPServerConfigACLAvailable, + ); + spyOn(API.experimental, "updateMCPServerConfigACL").mockResolvedValue(); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect( + body.queryByRole("dialog", { name: "Share server" }), + ).not.toBeInTheDocument(), + ); + + currentServerACL = refreshedACL; + await userEvent.click(body.getByRole("button", { name: "Open sharing" })); + + expect( + await body.findByRole("row", { + name: new RegExp(MockUserOwner.username, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("row", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeInTheDocument(); + expect( + body.queryByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).not.toBeInTheDocument(); + expect(API.experimental.getMCPServerConfigACL).toHaveBeenCalledTimes(2); + }, +}; + +export const SaveErrorKeepsEditorOpen: Story = { + beforeEach: () => + mockRequests({ + acl: populatedACL, + updateError: new Error("Unable to save ACL"), + }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click(body.getByRole("button", { name: "Save sharing" })); + + const alert = await body.findByRole("alert"); + expect(alert).toHaveTextContent("Unable to save ACL"); + expect(body.getByRole("dialog", { name: "Share server" })).toHaveAttribute( + "data-state", + "open", + ); + expect( + body.queryByRole("row", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).not.toBeInTheDocument(); + expect(body.getByRole("button", { name: "Save sharing" })).toBeEnabled(); + expect(args.onOpenChange).not.toHaveBeenCalledWith(false); + }, +}; + +export const CancelDiscardsDraft: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + expect(API.experimental.updateMCPServerConfigACL).not.toHaveBeenCalled(); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx new file mode 100644 index 00000000000..1fa2ff2cf83 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx @@ -0,0 +1,373 @@ +import isEqual from "lodash/isEqual"; +import { Trash2Icon, UserPlusIcon } from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; +import { + mcpServerConfigACL, + updateMCPServerConfigACL, +} from "#/api/queries/chats"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogActions, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { getGroupSubtitle, isGroup } from "#/modules/groups"; +import { + MCPServerPrincipalAutocomplete, + type MCPServerPrincipalAutocompleteValue, +} from "./MCPServerPrincipalAutocomplete"; + +type MCPServerSharingDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + serverId: string; + serverName: string; +}; + +type DraftACL = { + user_roles: Record; + group_roles: Record; +}; + +const emptyDraft = (): DraftACL => ({ user_roles: {}, group_roles: {} }); + +const buildRoleDelta = ( + initial: Record, + current: Record, +): Record => { + const delta: Record = {}; + for (const [principalId, role] of Object.entries(current)) { + if (initial[principalId] !== role) { + delta[principalId] = role; + } + } + for (const principalId of Object.keys(initial)) { + if (current[principalId] === undefined) { + delta[principalId] = ""; + } + } + return delta; +}; + +const buildACLDelta = ( + initial: DraftACL, + current: DraftACL, +): TypesGen.UpdateMCPServerConfigACLRequest => { + const userRoles = buildRoleDelta(initial.user_roles, current.user_roles); + const groupRoles = buildRoleDelta(initial.group_roles, current.group_roles); + return { + ...(Object.keys(userRoles).length > 0 ? { user_roles: userRoles } : {}), + ...(Object.keys(groupRoles).length > 0 ? { group_roles: groupRoles } : {}), + }; +}; + +export const MCPServerSharingDialog: FC = ({ + open, + onOpenChange, + organizationId, + serverId, + serverName, +}) => { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(emptyDraft); + const [initialACL, setInitialACL] = useState(null); + const [userIdentities, setUserIdentities] = useState< + Record + >({}); + const [groupIdentities, setGroupIdentities] = useState< + Record + >({}); + const [initialized, setInitialized] = useState(false); + const [selectedOption, setSelectedOption] = + useState(null); + + const aclQuery = useQuery({ + ...mcpServerConfigACL(organizationId, serverId), + enabled: open && initialized, + }); + const updateMutation = useMutation(updateMCPServerConfigACL(queryClient)); + + useEffect(() => { + if (!open || initialized) { + return; + } + let cancelled = false; + void aclQuery.refetch().then(({ data, isError }) => { + if (cancelled || isError || !data) { + return; + } + const snapshot = { + user_roles: Object.fromEntries( + data.users.map((user) => [user.id, user.role]), + ), + group_roles: Object.fromEntries( + data.groups.map((group) => [group.id, group.role]), + ), + }; + setInitialACL(snapshot); + setDraft(snapshot); + setUserIdentities( + Object.fromEntries(data.users.map((user) => [user.id, user])), + ); + setGroupIdentities( + Object.fromEntries(data.groups.map((group) => [group.id, group])), + ); + setInitialized(true); + }); + return () => { + cancelled = true; + }; + }, [aclQuery.refetch, initialized, open]); + + const reset = () => { + setDraft(emptyDraft()); + setInitialACL(null); + setUserIdentities({}); + setGroupIdentities({}); + setInitialized(false); + setSelectedOption(null); + updateMutation.reset(); + }; + const close = () => { + reset(); + onOpenChange(false); + }; + + const userIds = Object.keys(draft.user_roles); + const groupIds = Object.keys(draft.group_roles); + const excludedPrincipalIds = [...userIds, ...groupIds]; + const loadError = !initialized ? aclQuery.error : null; + const refetchError = initialized ? aclQuery.error : null; + const isLoading = !loadError && open && !initialized; + const isEmpty = userIds.length === 0 && groupIds.length === 0; + const isDirty = + initialized && initialACL !== null && !isEqual(draft, initialACL); + + const addSelectedPrincipal = () => { + if (!selectedOption) { + return; + } + if (isGroup(selectedOption)) { + setGroupIdentities((current) => ({ + ...current, + [selectedOption.id]: selectedOption, + })); + setDraft((current) => ({ + ...current, + group_roles: { ...current.group_roles, [selectedOption.id]: "read" }, + })); + } else { + setUserIdentities((current) => ({ + ...current, + [selectedOption.id]: selectedOption, + })); + setDraft((current) => ({ + ...current, + user_roles: { ...current.user_roles, [selectedOption.id]: "read" }, + })); + } + setSelectedOption(null); + }; + + const removeUser = (userId: string) => { + setDraft((current) => { + const userRoles = { ...current.user_roles }; + delete userRoles[userId]; + return { ...current, user_roles: userRoles }; + }); + }; + const removeGroup = (groupId: string) => { + setDraft((current) => { + const groupRoles = { ...current.group_roles }; + delete groupRoles[groupId]; + return { ...current, group_roles: groupRoles }; + }); + }; + + const save = () => { + if (!initialACL) { + return; + } + updateMutation.mutate( + { + organization: organizationId, + id: serverId, + req: buildACLDelta(initialACL, draft), + }, + { + onSuccess: () => { + toast.success(`Sharing for "${serverName}" updated.`); + close(); + }, + }, + ); + }; + + return ( + { + if (!nextOpen && !updateMutation.isPending) { + close(); + } + }} + > + + + Share server + + Choose which organization members and groups can use {serverName}. + + + + {updateMutation.error && } + {refetchError && } + {loadError && } + + {loadError ? null : isLoading ? ( +
+ + Loading server sharing +
+ ) : !initialized ? null : ( +
+
+
+ +
+ +
+ + {isEmpty ? ( +
+

+ No shared members or groups yet +

+

+ Add a member or group using the controls above. +

+
+ ) : ( + + + + Member + Role + + + + + {groupIds.map((groupId) => { + const group = groupIdentities[groupId]; + const name = + group?.display_name || group?.name || "Unknown group"; + return ( + + + + + Read + + + + + ); + })} + {userIds.map((userId) => { + const user = userIdentities[userId]; + const name = user?.username || "Unknown user"; + return ( + + + + + Read + + + + + ); + })} + +
+ )} +
+ )} + + + + +
+
+ ); +}; From ab5d87e1a83965e1c69b73440e15fbe5b5d1a229 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:16:38 +0000 Subject: [PATCH 04/15] feat(site): wire MCP server sharing controls --- .../AISettingsSidebarView.stories.tsx | 13 ++++++ .../MCPServersPage/MCPServersPage.stories.tsx | 44 ++++++++++++++++++- .../UpdateMCPServerPage.tsx | 1 + .../UpdateMCPServerPageView.stories.tsx | 43 ++++++++++++++++++ .../UpdateMCPServerPageView.tsx | 3 ++ .../components/MCPServerForm.tsx | 15 +++++++ .../components/MCPServerFormHeader.tsx | 35 +++++++++++---- 7 files changed, 144 insertions(+), 10 deletions(-) diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index ea871982da2..8b024d4840b 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -244,6 +244,19 @@ export const MCPServersForDeleteOnlyAdmin: Story = { }, }; +export const MCPServersForOrganizationShareOnlyAdmin: Story = { + args: { + permissions: MockNoPermissions, + canShareOrganizationMCPServers: 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: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 409526712a5..eaba3e1e841 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -45,6 +45,7 @@ type MCPOrganizationStoryPermissions = Readonly<{ create?: boolean; update?: boolean; delete?: boolean; + share?: boolean; }>; const organizationPermissionsResponse = ( @@ -68,7 +69,9 @@ const organizationPermissionsResponse = ( ? permissions?.update : permission === "deleteMCPServerConfig" ? permissions?.delete - : false; + : permission === "shareMCPServerConfig" + ? permissions?.share + : false; return [key, Boolean(allowed)]; }), ); @@ -325,6 +328,45 @@ export const DeleteOnlyOrgAdminCanOpenMCPServer: Story = { }, }; +export const ShareOnlyOrgAdminCanOpenMCPServer: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: false, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: [ + { path: "/ai/settings/mcp-servers", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/:serverId", + element: , + }, + ], + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { share: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /Coder/ })); + await expect( + await canvas.findByRole("heading", { + name: `detail-org:${MockDefaultOrganization.name}`, + }), + ).toBeVisible(); + }, +}; + export const UpdateOnlyOrgAdminUsesAuthorizedOrganization: Story = { parameters: { permissions: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 144ee93c89d..8e29456babd 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -154,6 +154,7 @@ const UpdateMCPServerPage: FC = () => { isSaving={updateMutation.isPending} isDeleting={deleteMutation.isPending} canSelectUserOIDC={permissions.editDeploymentConfig} + canShareServer={canShare} onCancel={() => void navigate(listPath)} onUpdateServer={ canUpdateServer diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx index be3535f06a6..7ff5c3499f0 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -133,3 +133,46 @@ export const DeleteOnly: Story = { ).toBeEnabled(); }, }; + +export const ShareOnlyAccess: Story = { + args: { + canShareServer: true, + onUpdateServer: undefined, + onDeleteServer: undefined, + onToggleEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + const serverActions = canvas.getByRole("button", { + name: "Server actions", + }); + await userEvent.click(serverActions); + expect( + body.queryByRole("menuitem", { name: "Remove" }), + ).not.toBeInTheDocument(); + await userEvent.click( + await body.findByRole("menuitem", { name: "Share server" }), + ); + await expect( + await body.findByRole("dialog", { name: "Share server" }), + ).toBeVisible(); + }, +}; + +export const NoShareReadOnlyAccess: Story = { + args: { + canShareServer: false, + onUpdateServer: undefined, + onDeleteServer: undefined, + onToggleEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + expect( + canvas.queryByRole("button", { name: "Server actions" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index dda7efcdc0d..a8e4b30351b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -12,6 +12,7 @@ interface UpdateMCPServerPageViewProps { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + canShareServer?: boolean; onUpdateServer?: ( serverId: string, req: TypesGen.UpdateMCPServerConfigRequest, @@ -29,6 +30,7 @@ const UpdateMCPServerPageView: FC = ({ isSaving, isDeleting, canSelectUserOIDC, + canShareServer, onUpdateServer, onDeleteServer, onToggleEnabled, @@ -51,6 +53,7 @@ const UpdateMCPServerPageView: FC = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + canShareServer={canShareServer} onUpdateServer={onUpdateServer} onDeleteServer={onDeleteServer} onToggleEnabled={onToggleEnabled} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index f7066458779..9863c5d2d17 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -5,6 +5,7 @@ import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { MCPServerFormDialogs } from "./MCPServerFormDialogs"; import { MCPServerFormFields } from "./MCPServerFormFields"; import { MCPServerFormHeader } from "./MCPServerFormHeader"; +import { MCPServerSharingDialog } from "./MCPServerSharingDialog"; import { buildCreateMCPServerConfigRequest, buildInitialMCPServerFormValues, @@ -21,6 +22,7 @@ type MCPServerFormCreateProps = { isSaving: boolean; isDeleting?: false; canSelectUserOIDC: boolean; + canShareServer?: false; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -36,6 +38,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + canShareServer?: boolean; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -54,6 +57,7 @@ export const MCPServerForm: FC = ({ isSaving, isDeleting = false, canSelectUserOIDC, + canShareServer = false, onCreateServer, onUpdateServer, onDeleteServer, @@ -66,6 +70,7 @@ export const MCPServerForm: FC = ({ const [showAuth, setShowAuth] = useState(false); const [showBehavior, setShowBehavior] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); + const [sharingOpen, setSharingOpen] = useState(false); const form = useFormik({ initialValues: buildInitialMCPServerFormValues(server), @@ -110,6 +115,7 @@ export const MCPServerForm: FC = ({ onRequestDelete={ onDeleteServer ? () => setConfirmingDelete(true) : undefined } + onShareServer={canShareServer ? () => setSharingOpen(true) : undefined} onToggleEnabled={onToggleEnabled} />
@@ -129,6 +135,15 @@ export const MCPServerForm: FC = ({ setShowBehavior={setShowBehavior} />
+ {server && canShareServer && ( + + )} void; + onShareServer?: () => void; onToggleEnabled?: (enabled: boolean) => void; } @@ -50,6 +57,7 @@ export const MCPServerFormHeader: FC = ({ isEditing, isDisabled, onRequestDelete, + onShareServer, onToggleEnabled, }) => { const disabledReasonId = useId(); @@ -59,7 +67,7 @@ export const MCPServerFormHeader: FC = ({ <>
{listPath && } - {isEditing && server && onRequestDelete && ( + {isEditing && server && (onRequestDelete || onShareServer) && ( - - - Remove - + {onShareServer && ( + + + Share server + + )} + {onShareServer && onRequestDelete && } + {onRequestDelete && ( + + + Remove + + )} )} From 82336269a1fa589b02aac07ce015076dda5bf66c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:19:43 +0000 Subject: [PATCH 05/15] test(site): stabilize MCP share access story --- .../UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx index 7ff5c3499f0..bb5bf510c7f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -157,7 +157,7 @@ export const ShareOnlyAccess: Story = { ); await expect( await body.findByRole("dialog", { name: "Share server" }), - ).toBeVisible(); + ).toHaveAttribute("data-state", "open"); }, }; From 52a7ecb77542d5dd8c2b86e12c0ff6ddf40a4527 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:23:12 +0000 Subject: [PATCH 06/15] refactor(site): narrow MCP permission query keys --- site/src/api/queries/chats.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 6b67c79b2c1..34739916358 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2555,7 +2555,8 @@ export const updateMCPServerConfigACL = (queryClient: QueryClient) => ({ predicate: ({ queryKey }) => queryKey[0] === "organizations" && queryKey[2] === "permissions" && - (queryKey[1] as readonly string[]).includes(organization), + Array.isArray(queryKey[1]) && + queryKey[1].includes(organization), }), ]); }, From 5f4a1ed40e4be733d8f30de93623054dddc87d68 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:58:05 +0000 Subject: [PATCH 07/15] refactor(site): reuse typed query keys in MCP ACL queries --- site/src/api/queries/chats.test.ts | 17 ++++++++++++----- site/src/api/queries/chats.ts | 8 ++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index f893a9dcdeb..2ae4ecaa9d2 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1,6 +1,11 @@ import { QueryClient, QueryObserver } from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; +import { authorizationKey } from "#/api/queries/authCheck"; +import { + organizations, + organizationsPermissions, +} from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatWatchEventKinds } from "#/api/typesGenerated"; import { @@ -389,7 +394,9 @@ describe("MCP server ACL query factories", () => { }); it("gets and sparsely updates an organization-scoped ACL", async () => { - const req = { user_roles: { "user-1": "read" as const } }; + const req: TypesGen.UpdateMCPServerConfigACLRequest = { + user_roles: { "user-1": "read" }, + }; vi.mocked(API.experimental.getMCPServerConfigACL).mockResolvedValue( MockMCPServerConfigACL, ); @@ -410,12 +417,12 @@ describe("MCP server ACL query factories", () => { mcpServerConfigACLKey(organization, serverId), mcpServerConfigKey(organization, serverId), mcpServerConfigsKey(organization), - ["authorization", "mcp-servers"], - ["organizations", [organization], "permissions"], + [...authorizationKey, "mcp-servers"], + organizationsPermissions([organization]).queryKey, ] as const; const unaffectedKeys = [ - ["organizations"], - ["organizations", [otherOrganization], "permissions"], + organizations().queryKey, + organizationsPermissions([otherOrganization]).queryKey, ] as const; for (const key of [...keys, ...unaffectedKeys]) { queryClient.setQueryData(key, {}); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 34739916358..e6ab1f04cd3 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -17,6 +17,7 @@ import { projectEditedConversationIntoCache, reconcileEditedMessageInCache, } from "./chatMessageEdits"; +import { organizationsPermissions } from "./organizations"; const chatCollectionsKey = ["chats", "collections"] as const; @@ -2537,6 +2538,9 @@ export const updateMCPServerConfigACL = (queryClient: QueryClient) => ({ variables: UpdateMCPServerConfigACLMutationArgs, ) => { const { organization, id } = variables; + const permissionsQueryKey = organizationsPermissions([ + organization, + ]).queryKey; await Promise.all([ queryClient.invalidateQueries({ queryKey: mcpServerConfigACLKey(organization, id), @@ -2553,8 +2557,8 @@ export const updateMCPServerConfigACL = (queryClient: QueryClient) => ({ queryClient.invalidateQueries({ queryKey: authorizationKey }), queryClient.invalidateQueries({ predicate: ({ queryKey }) => - queryKey[0] === "organizations" && - queryKey[2] === "permissions" && + queryKey[0] === permissionsQueryKey[0] && + queryKey[2] === permissionsQueryKey[2] && Array.isArray(queryKey[1]) && queryKey[1].includes(organization), }), From 82b68dc9094ddb9e6498c3c0dfcd450f8a8110c1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:22:23 +0000 Subject: [PATCH 08/15] fix(site): surface AI settings navigation for MCP share-only users --- coderd/mcp.go | 3 +- coderd/mcp_acl_available_test.go | 48 +++++++++++++++++++ site/src/modules/dashboard/Navbar/Navbar.tsx | 9 +++- .../modules/management/AISettingsSidebar.tsx | 18 ++----- .../AISettingsIndexRedirect.test.tsx | 32 +++++++++++++ .../AISettingsIndexRedirect.tsx | 13 +++++ .../MCPServersPage/organizationSharing.ts | 32 +++++++++++++ 7 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts diff --git a/coderd/mcp.go b/coderd/mcp.go index 7ba73157c54..8b721009b56 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -535,7 +535,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { hasFullView := api.Authorize(r, policy.ActionUpdate, config) || api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(config.OrganizationID)) if !hasFullView && !config.Enabled && - !api.Authorize(r, policy.ActionDelete, config) { + !api.Authorize(r, policy.ActionDelete, config) && + !api.Authorize(r, policy.ActionShare, config) { httpapi.ResourceNotFound(rw) return } diff --git a/coderd/mcp_acl_available_test.go b/coderd/mcp_acl_available_test.go index f449f4efaef..04d703a10aa 100644 --- a/coderd/mcp_acl_available_test.go +++ b/coderd/mcp_acl_available_test.go @@ -112,6 +112,54 @@ func TestMCPServerConfigACLAvailable(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } +func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, rawDB, _ := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), false) + + sharerClient, sharer := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) + requireSDKError(t, err, http.StatusNotFound) + + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "MCP Server Config Sharer", + OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionShare, + }, + }, + }) + require.NoError(t, err) + _, err = client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, sharer.ID.String(), codersdk.UpdateRoles{ + Roles: []string{role.Name}, + }) + require.NoError(t, err) + + // Share-authorized callers keep access to disabled configs so they can + // still open them and manage sharing, matching the list behavior. + fetched, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Equal(t, config.ID, fetched.ID) + require.False(t, fetched.Enabled) + require.Empty(t, fetched.URL) + + configs, err := sharerClient.MCPServerConfigs(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, config.ID, configs[0].ID) +} + func TestMCPServerConfigACLWorkspaceSharingModes(t *testing.T) { t.Parallel() diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index c1e6af8c9c2..cb6ceccb1a1 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -9,6 +9,7 @@ import { canAccessAnyChatModelConfig, canViewDeploymentSettings, } from "#/modules/permissions"; +import { useCanShareOrganizationMCPServers } from "#/pages/AISettingsPage/MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "#/pages/AISettingsPage/ModelsPage/organizationModels"; import { useFeatureVisibility } from "../useFeatureVisibility"; import { NavbarView } from "./NavbarView"; @@ -34,7 +35,7 @@ export const Navbar: React.FC = () => { featureVisibility.connection_log && permissions.viewAnyConnectionLog; const canViewAIBridge = featureVisibility.aibridge && permissions.viewAnyAIBridgeInterception; - const canViewAISettings = + const canViewSiteWideAISettings = permissions.viewAnyAIProvider || permissions.viewAIGatewayKeys || permissions.editDeploymentConfig || @@ -43,6 +44,12 @@ export const Navbar: React.FC = () => { permissions.updateAnyMCPServerConfig || permissions.deleteAnyMCPServerConfig || canAccessAnyModel; + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !canViewSiteWideAISettings }, + ); + const canViewAISettings = + canViewSiteWideAISettings || organizationMCPSharing.canShare; const canViewModels = !canViewAISettings && accessibleModelOrgsQuery.organizations.length > 0; const canCreateChat = permissions.createChat; diff --git a/site/src/modules/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx index 50887e14575..72829a69b9a 100644 --- a/site/src/modules/management/AISettingsSidebar.tsx +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -1,9 +1,8 @@ import type { FC } from "react"; -import { useQuery } from "react-query"; -import { organizationsPermissions } from "#/api/queries/organizations"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; +import { useCanShareOrganizationMCPServers } from "#/pages/AISettingsPage/MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "#/pages/AISettingsPage/ModelsPage/organizationModels"; /** @@ -13,16 +12,9 @@ export const AISettingsSidebar: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const accessibleOrgsQuery = useAccessibleModelOrganizations(organizations); - const organizationPermissionsQuery = useQuery({ - ...organizationsPermissions( - organizations.map((organization) => organization.id), - ), - enabled: !permissions.editDeploymentConfig, - }); - const canShareOrganizationMCPServers = organizations.some( - (organization) => - organizationPermissionsQuery.data?.[organization.id] - ?.shareMCPServerConfig, + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !permissions.editDeploymentConfig }, ); return ( @@ -31,7 +23,7 @@ export const AISettingsSidebar: FC = () => { canAccessOrganizationModels={ (accessibleOrgsQuery.organizations.length ?? 0) > 0 } - canShareOrganizationMCPServers={canShareOrganizationMCPServers} + canShareOrganizationMCPServers={organizationMCPSharing.canShare} /> ); }; diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx index 668bf587db8..b8cffe85c04 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx @@ -57,3 +57,35 @@ it("redirects a deployment administrator to Coder Agents", async () => { await screen.findByText("Coder Agents"); expect(router.state.location.pathname).toBe("/ai/settings/coder-agents"); }); + +it("redirects an organization MCP sharer to MCP servers", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.spyOn(API.experimental, "getChatModels").mockRejectedValue({ + isAxiosError: true, + response: { status: 403 }, + }); + vi.spyOn(API, "checkAuthorization").mockResolvedValue({ + [`${MockDefaultOrganization.id}.shareMCPServerConfig`]: true, + }); + const router = createMemoryRouter( + [ + { path: "/ai/settings", element: }, + { + path: "/ai/settings/mcp-servers", + element:
MCP Servers
, + }, + ], + { initialEntries: ["/ai/settings"] }, + ); + + render( + + + , + ); + + await screen.findByText("MCP Servers"); + expect(router.state.location.pathname).toBe("/ai/settings/mcp-servers"); +}); diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx index 320f28ed2dc..715aa96aebd 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx @@ -4,12 +4,17 @@ import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { canAccessAnyChatModelConfig } from "#/modules/permissions"; +import { useCanShareOrganizationMCPServers } from "./MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "./ModelsPage/organizationModels"; export const AISettingsIndexRedirect = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const accessibleOrgsQuery = useAccessibleModelOrganizations(organizations); + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !permissions.editDeploymentConfig }, + ); if (permissions.viewAnyAIProvider) { return ; @@ -47,6 +52,14 @@ export const AISettingsIndexRedirect = () => { return ; } + if (organizationMCPSharing.isLoading) { + return ; + } + + if (organizationMCPSharing.canShare) { + return ; + } + if (permissions.editDeploymentConfig) { return ; } diff --git a/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts b/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts new file mode 100644 index 00000000000..b111de49f4a --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts @@ -0,0 +1,32 @@ +import { useQuery } from "react-query"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import type { Organization } from "#/api/typesGenerated"; + +type UseCanShareOrganizationMCPServersOptions = { + enabled?: boolean; +}; + +// Discovers whether the user can share MCP servers in any organization so +// top-level navigation can admit share-only users who hold no site-wide MCP +// permissions. +export const useCanShareOrganizationMCPServers = ( + organizations: readonly Organization[], + options: UseCanShareOrganizationMCPServersOptions = {}, +) => { + const enabled = (options.enabled ?? true) && organizations.length > 0; + const organizationsPermissionsQuery = useQuery({ + ...organizationsPermissions( + organizations.map((organization) => organization.id), + ), + enabled, + }); + return { + canShare: organizations.some( + (organization) => + organizationsPermissionsQuery.data?.[organization.id] + ?.shareMCPServerConfig, + ), + isLoading: enabled && organizationsPermissionsQuery.isLoading, + error: organizationsPermissionsQuery.error, + }; +}; From 115f9780dc4753c50c4bca442252e3262f943464 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:44:58 +0000 Subject: [PATCH 09/15] test(site): cover share-only MCP navigation with interaction stories --- coderd/coderd.go | 2 +- coderd/mcp_acl_available_test.go | 7 +- .../dashboard/DashboardLayout.stories.tsx | 44 +++++++++ .../AISettingsIndexRedirect.stories.tsx | 89 +++++++++++++++++++ .../AISettingsIndexRedirect.test.tsx | 32 ------- 5 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx diff --git a/coderd/coderd.go b/coderd/coderd.go index 5a9ae063cea..5d6ca35f149 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1393,7 +1393,7 @@ func New(options *Options) *API { r.Post("/", api.createMCPServerConfig) r.Route("/{mcpserverconfig}", func(r chi.Router) { r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, - policy.ActionRead, policy.ActionUpdate, policy.ActionDelete)).Get("/", api.getMCPServerConfig) + policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare)).Get("/", api.getMCPServerConfig) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, policy.ActionUpdate)).Patch("/", api.updateMCPServerConfig) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, diff --git a/coderd/mcp_acl_available_test.go b/coderd/mcp_acl_available_test.go index 04d703a10aa..b2aa15ce17c 100644 --- a/coderd/mcp_acl_available_test.go +++ b/coderd/mcp_acl_available_test.go @@ -130,10 +130,6 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { DisplayName: "MCP Server Config Sharer", OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, OrgPermissions: database.CustomRolePermissions{ - { - ResourceType: rbac.ResourceMCPServerConfig.Type, - Action: policy.ActionRead, - }, { ResourceType: rbac.ResourceMCPServerConfig.Type, Action: policy.ActionShare, @@ -147,7 +143,8 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { require.NoError(t, err) // Share-authorized callers keep access to disabled configs so they can - // still open them and manage sharing, matching the list behavior. + // still open them and manage sharing, matching the list behavior. The + // role grants share without read to prove the whole path admits it. fetched, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) require.NoError(t, err) require.Equal(t, config.ID, fetched.ID) diff --git a/site/src/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx index c23c84aab63..d57b63f83f6 100644 --- a/site/src/modules/dashboard/DashboardLayout.stories.tsx +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -47,6 +47,14 @@ const pageContent = ( ); +const mcpServersRouter = reactRouterParameters({ + location: { path: "/" }, + routing: [ + { path: "/", useStoryElement: true }, + { path: "/ai/settings", element:

AI settings for sharers

}, + ], +}); + const modelSettingsRouter = reactRouterParameters({ location: { path: "/" }, routing: [ @@ -167,6 +175,42 @@ export const ACLReadableMemberCanOpenModels: Story = { play: openModels, }; +export const CustomOrganizationRoleCanOpenMCPServers: Story = { + parameters: { + pixel: { matrix: pixelWithDesktop }, + user: MockUserMember, + permissions: MockNoPermissions, + reactRouter: mcpServersRouter, + queries: [ + { key: buildInfoKey, data: MockBuildInfo }, + { key: updateCheckQueryKey, data: MockUpdateCheck }, + { key: deploymentStatsQueryKey, data: MockDeploymentStats }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: { + ...MockNoOrganizationPermissions, + shareMCPServerConfig: true, + }, + }, + }, + ], + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await user.click( + await canvas.findByRole("button", { name: "Admin settings" }), + ); + await user.click(await screen.findByRole("menuitem", { name: "AI" })); + await expect( + await canvas.findByRole("heading", { + name: "AI settings for sharers", + }), + ).toBeInTheDocument(); + }, +}; + export const UpdateAvailable: Story = { parameters: { queries: [ diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx new file mode 100644 index 00000000000..dce1e077279 --- /dev/null +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { chatModels } from "#/api/queries/chats"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import { + MockDefaultOrganization, + MockNoOrganizationPermissions, + MockNoPermissions, + MockUserMember, +} from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; +import { AISettingsIndexRedirect } from "./AISettingsIndexRedirect"; + +const meta: Meta = { + title: "pages/AISettingsPage/AISettingsIndexRedirect", + component: AISettingsIndexRedirect, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + user: MockUserMember, + permissions: MockNoPermissions, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers", + element:

Organization MCP servers

, + }, + { path: "/ai/settings/providers", element:

AI providers

}, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const OrganizationMCPSharerRedirectsToMCPServers: Story = { + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: { + ...MockNoOrganizationPermissions, + shareMCPServerConfig: true, + }, + }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByRole("heading", { + name: "Organization MCP servers", + }), + ).toBeInTheDocument(); + }, +}; + +export const MemberWithoutMCPSharingFallsBack: Story = { + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: MockNoOrganizationPermissions, + }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByRole("heading", { name: "AI providers" }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx index b8cffe85c04..668bf587db8 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx @@ -57,35 +57,3 @@ it("redirects a deployment administrator to Coder Agents", async () => { await screen.findByText("Coder Agents"); expect(router.state.location.pathname).toBe("/ai/settings/coder-agents"); }); - -it("redirects an organization MCP sharer to MCP servers", async () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - vi.spyOn(API.experimental, "getChatModels").mockRejectedValue({ - isAxiosError: true, - response: { status: 403 }, - }); - vi.spyOn(API, "checkAuthorization").mockResolvedValue({ - [`${MockDefaultOrganization.id}.shareMCPServerConfig`]: true, - }); - const router = createMemoryRouter( - [ - { path: "/ai/settings", element: }, - { - path: "/ai/settings/mcp-servers", - element:
MCP Servers
, - }, - ], - { initialEntries: ["/ai/settings"] }, - ); - - render( - - - , - ); - - await screen.findByText("MCP Servers"); - expect(router.state.location.pathname).toBe("/ai/settings/mcp-servers"); -}); From ff0a96b0cdf569062063b892a9a155b00ac72f63 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:01:08 +0000 Subject: [PATCH 10/15] fix(coderd): keep share-only MCP detail route contract --- coderd/coderd.go | 2 +- coderd/mcp_acl_available_test.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 5d6ca35f149..5a9ae063cea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1393,7 +1393,7 @@ func New(options *Options) *API { r.Post("/", api.createMCPServerConfig) r.Route("/{mcpserverconfig}", func(r chi.Router) { r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, - policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare)).Get("/", api.getMCPServerConfig) + policy.ActionRead, policy.ActionUpdate, policy.ActionDelete)).Get("/", api.getMCPServerConfig) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, policy.ActionUpdate)).Patch("/", api.updateMCPServerConfig) r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize, diff --git a/coderd/mcp_acl_available_test.go b/coderd/mcp_acl_available_test.go index b2aa15ce17c..5e0f0382fac 100644 --- a/coderd/mcp_acl_available_test.go +++ b/coderd/mcp_acl_available_test.go @@ -112,7 +112,7 @@ func TestMCPServerConfigACLAvailable(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } -func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { +func TestMCPServerConfigDisabledSharerFetch(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -130,6 +130,10 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { DisplayName: "MCP Server Config Sharer", OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionRead, + }, { ResourceType: rbac.ResourceMCPServerConfig.Type, Action: policy.ActionShare, @@ -142,9 +146,10 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) { }) require.NoError(t, err) - // Share-authorized callers keep access to disabled configs so they can - // still open them and manage sharing, matching the list behavior. The - // role grants share without read to prove the whole path admits it. + // Delegated sharers hold read alongside share: share alone deliberately + // does not admit the detail route (enterprise share-only contract), but + // sharers with read keep access to disabled configs so they can still + // open them and manage sharing, matching the list behavior. fetched, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) require.NoError(t, err) require.Equal(t, config.ID, fetched.ID) From 220826f96bcde574aefbf991e1c6b6740454bdd3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:16:29 +0000 Subject: [PATCH 11/15] fix(site): surface MCP permission lookup failure in AI settings redirect --- .../AISettingsIndexRedirect.stories.tsx | 27 ++++++++++++++++++- .../AISettingsIndexRedirect.tsx | 4 +++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx index dce1e077279..3b6f9ba6869 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen } from "storybook/test"; +import { expect, screen, spyOn } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; import { chatModels } from "#/api/queries/chats"; import { organizationsPermissions } from "#/api/queries/organizations"; import { @@ -87,3 +88,27 @@ export const MemberWithoutMCPSharingFallsBack: Story = { ).toBeInTheDocument(); }, }; + +export const MCPSharingPermissionLookupFailureShowsError: Story = { + beforeEach: () => { + spyOn(API, "checkAuthorization").mockRejectedValue( + new Error("Unable to load organization permissions"), + ); + }, + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByText("Unable to load organization permissions"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: "AI providers" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx index 715aa96aebd..c9d45e1dc71 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx @@ -56,6 +56,10 @@ export const AISettingsIndexRedirect = () => { return ; } + if (organizationMCPSharing.error !== null) { + return ; + } + if (organizationMCPSharing.canShare) { return ; } From 15e5b07c3b0cc6860bb3905b376bbf3a447f66b5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:51:00 +0000 Subject: [PATCH 12/15] refactor(site): share ACL dialog implementation --- .../components/MCPServerSharingDialog.tsx | 440 ++++------------ .../components/ChatModelSharingDialog.tsx | 470 ++++++------------ .../components/ResourceSharingDialog.tsx | 434 ++++++++++++++++ 3 files changed, 679 insertions(+), 665 deletions(-) create mode 100644 site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx index 1fa2ff2cf83..7261b1d56ec 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx @@ -1,6 +1,4 @@ -import isEqual from "lodash/isEqual"; -import { Trash2Icon, UserPlusIcon } from "lucide-react"; -import { type FC, useEffect, useState } from "react"; +import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { @@ -8,28 +6,13 @@ import { updateMCPServerConfigACL, } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogActions, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; import { getGroupSubtitle, isGroup } from "#/modules/groups"; +import { + ResourceSharingDialog, + type SharingDialogData, + type SharingPrincipal, + type SharingPrincipalSelection, +} from "../../components/ResourceSharingDialog"; import { MCPServerPrincipalAutocomplete, type MCPServerPrincipalAutocompleteValue, @@ -43,331 +26,114 @@ type MCPServerSharingDialogProps = { serverName: string; }; -type DraftACL = { - user_roles: Record; - group_roles: Record; -}; - -const emptyDraft = (): DraftACL => ({ user_roles: {}, group_roles: {} }); - -const buildRoleDelta = ( - initial: Record, - current: Record, -): Record => { - const delta: Record = {}; - for (const [principalId, role] of Object.entries(current)) { - if (initial[principalId] !== role) { - delta[principalId] = role; - } - } - for (const principalId of Object.keys(initial)) { - if (current[principalId] === undefined) { - delta[principalId] = ""; - } - } - return delta; -}; - -const buildACLDelta = ( - initial: DraftACL, - current: DraftACL, -): TypesGen.UpdateMCPServerConfigACLRequest => { - const userRoles = buildRoleDelta(initial.user_roles, current.user_roles); - const groupRoles = buildRoleDelta(initial.group_roles, current.group_roles); - return { - ...(Object.keys(userRoles).length > 0 ? { user_roles: userRoles } : {}), - ...(Object.keys(groupRoles).length > 0 ? { group_roles: groupRoles } : {}), - }; -}; - -export const MCPServerSharingDialog: FC = ({ - open, +type MCPServerPrincipal = Exclude; + +const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ + id: group.id, + name: group.display_name || group.name, + subtitle: getGroupSubtitle(group), + avatarUrl: group.avatar_url, +}); + +const userPrincipal = (user: TypesGen.MinimalUser): SharingPrincipal => ({ + id: user.id, + name: user.username, + subtitle: user.name || "User", + avatarUrl: user.avatar_url, +}); + +const sharingDialogData = ( + acl: TypesGen.MCPServerConfigACL, +): SharingDialogData => ({ + acl: { + user_roles: Object.fromEntries( + acl.users.map((user) => [user.id, user.role]), + ), + group_roles: Object.fromEntries( + acl.groups.map((group) => [group.id, group.role]), + ), + }, + principals: { + users: Object.fromEntries( + acl.users.map((user) => [user.id, userPrincipal(user)]), + ), + groups: Object.fromEntries( + acl.groups.map((group) => [group.id, groupPrincipal(group)]), + ), + }, +}); + +const selectedPrincipal = ( + option: MCPServerPrincipal, +): SharingPrincipalSelection => + isGroup(option) + ? { kind: "group", principal: groupPrincipal(option) } + : { kind: "user", principal: userPrincipal(option) }; + +type OpenMCPServerSharingDialogProps = Omit< + MCPServerSharingDialogProps, + "open" +>; + +const OpenMCPServerSharingDialog: FC = ({ onOpenChange, organizationId, serverId, serverName, }) => { const queryClient = useQueryClient(); - const [draft, setDraft] = useState(emptyDraft); - const [initialACL, setInitialACL] = useState(null); - const [userIdentities, setUserIdentities] = useState< - Record - >({}); - const [groupIdentities, setGroupIdentities] = useState< - Record - >({}); - const [initialized, setInitialized] = useState(false); - const [selectedOption, setSelectedOption] = - useState(null); - - const aclQuery = useQuery({ - ...mcpServerConfigACL(organizationId, serverId), - enabled: open && initialized, - }); + const aclOptions = mcpServerConfigACL(organizationId, serverId); + const aclQuery = useQuery({ ...aclOptions, refetchOnMount: "always" }); const updateMutation = useMutation(updateMCPServerConfigACL(queryClient)); + const data = aclQuery.data ? sharingDialogData(aclQuery.data) : undefined; - useEffect(() => { - if (!open || initialized) { - return; - } - let cancelled = false; - void aclQuery.refetch().then(({ data, isError }) => { - if (cancelled || isError || !data) { - return; - } - const snapshot = { - user_roles: Object.fromEntries( - data.users.map((user) => [user.id, user.role]), - ), - group_roles: Object.fromEntries( - data.groups.map((group) => [group.id, group.role]), - ), - }; - setInitialACL(snapshot); - setDraft(snapshot); - setUserIdentities( - Object.fromEntries(data.users.map((user) => [user.id, user])), - ); - setGroupIdentities( - Object.fromEntries(data.groups.map((group) => [group.id, group])), - ); - setInitialized(true); - }); - return () => { - cancelled = true; - }; - }, [aclQuery.refetch, initialized, open]); - - const reset = () => { - setDraft(emptyDraft()); - setInitialACL(null); - setUserIdentities({}); - setGroupIdentities({}); - setInitialized(false); - setSelectedOption(null); - updateMutation.reset(); - }; const close = () => { - reset(); onOpenChange(false); - }; - - const userIds = Object.keys(draft.user_roles); - const groupIds = Object.keys(draft.group_roles); - const excludedPrincipalIds = [...userIds, ...groupIds]; - const loadError = !initialized ? aclQuery.error : null; - const refetchError = initialized ? aclQuery.error : null; - const isLoading = !loadError && open && !initialized; - const isEmpty = userIds.length === 0 && groupIds.length === 0; - const isDirty = - initialized && initialACL !== null && !isEqual(draft, initialACL); - - const addSelectedPrincipal = () => { - if (!selectedOption) { - return; - } - if (isGroup(selectedOption)) { - setGroupIdentities((current) => ({ - ...current, - [selectedOption.id]: selectedOption, - })); - setDraft((current) => ({ - ...current, - group_roles: { ...current.group_roles, [selectedOption.id]: "read" }, - })); - } else { - setUserIdentities((current) => ({ - ...current, - [selectedOption.id]: selectedOption, - })); - setDraft((current) => ({ - ...current, - user_roles: { ...current.user_roles, [selectedOption.id]: "read" }, - })); - } - setSelectedOption(null); - }; - - const removeUser = (userId: string) => { - setDraft((current) => { - const userRoles = { ...current.user_roles }; - delete userRoles[userId]; - return { ...current, user_roles: userRoles }; - }); - }; - const removeGroup = (groupId: string) => { - setDraft((current) => { - const groupRoles = { ...current.group_roles }; - delete groupRoles[groupId]; - return { ...current, group_roles: groupRoles }; - }); - }; - - const save = () => { - if (!initialACL) { - return; - } - updateMutation.mutate( - { - organization: organizationId, - id: serverId, - req: buildACLDelta(initialACL, draft), - }, - { - onSuccess: () => { - toast.success(`Sharing for "${serverName}" updated.`); - close(); - }, - }, - ); + queryClient.removeQueries({ queryKey: aclOptions.queryKey, exact: true }); }; return ( - { - if (!nextOpen && !updateMutation.isPending) { - close(); - } - }} - > - - - Share server - - Choose which organization members and groups can use {serverName}. - - - - {updateMutation.error && } - {refetchError && } - {loadError && } - - {loadError ? null : isLoading ? ( -
- - Loading server sharing -
- ) : !initialized ? null : ( -
-
-
- -
- -
- - {isEmpty ? ( -
-

- No shared members or groups yet -

-

- Add a member or group using the controls above. -

-
- ) : ( - - - - Member - Role - - - - - {groupIds.map((groupId) => { - const group = groupIdentities[groupId]; - const name = - group?.display_name || group?.name || "Unknown group"; - return ( - - - - - Read - - - - - ); - })} - {userIds.map((userId) => { - const user = userIdentities[userId]; - const name = user?.username || "Unknown user"; - return ( - - - - - Read - - - - - ); - })} - -
- )} -
- )} - - - - -
-
+ Choose which organization members and groups can use {serverName}. + } + loadingLabel="Loading server sharing" + tableLabel="Shared server members and groups" + data={data} + loadError={data ? null : aclQuery.error} + refetchError={data ? aclQuery.error : null} + saveError={updateMutation.error} + isSaving={updateMutation.isPending} + readRole="read" + deletedRole="" + renderAutocomplete={({ value, onChange, excludedPrincipalIds }) => ( + + )} + getPrincipal={selectedPrincipal} + onClose={close} + onSave={(req) => + updateMutation.mutate( + { organization: organizationId, id: serverId, req }, + { + onSuccess: () => { + toast.success(`Sharing for "${serverName}" updated.`); + close(); + }, + }, + ) + } + /> ); }; + +export const MCPServerSharingDialog: FC = ( + props, +) => (props.open ? : null); diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx index cc5eefff08e..9030e275712 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx @@ -1,38 +1,21 @@ -import isEqual from "lodash/isEqual"; -import { Trash2Icon, UserPlusIcon } from "lucide-react"; -import { type FC, useEffect, useState } from "react"; +import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { chatModelACL, updateChatModelACL } from "#/api/queries/chats"; import { groupsByOrganization } from "#/api/queries/groups"; import { organizationMembers } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogActions, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; import { getGroupSubtitle, isGroup } from "#/modules/groups"; import { UserOrGroupAutocomplete, type UserOrGroupAutocompleteValue, } from "#/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete"; +import { + ResourceSharingDialog, + type SharingDialogData, + type SharingPrincipal, + type SharingPrincipalSelection, +} from "../../components/ResourceSharingDialog"; type ChatModelSharingDialogProps = { open: boolean; @@ -42,327 +25,158 @@ type ChatModelSharingDialogProps = { modelName: string; }; -type DraftACL = { - user_roles: Record; - group_roles: Record; -}; - -const emptyDraft = (): DraftACL => ({ user_roles: {}, group_roles: {} }); - -const buildRoleDelta = ( - initial: Record, - current: Record, -): Record => { - const delta: Record = {}; - for (const [principalId, role] of Object.entries(current)) { - if (initial[principalId] !== role) { - delta[principalId] = role; - } - } - for (const principalId of Object.keys(initial)) { - if (current[principalId] === undefined) { - delta[principalId] = ""; - } - } - return delta; -}; - -const buildACLDelta = ( - initial: DraftACL, - current: DraftACL, -): TypesGen.UpdateChatModelACLRequest => { - const userRoles = buildRoleDelta(initial.user_roles, current.user_roles); - const groupRoles = buildRoleDelta(initial.group_roles, current.group_roles); - return { - ...(Object.keys(userRoles).length > 0 ? { user_roles: userRoles } : {}), - ...(Object.keys(groupRoles).length > 0 ? { group_roles: groupRoles } : {}), - }; -}; - -export const ChatModelSharingDialog: FC = ({ - open, +type ChatModelPrincipal = Exclude; + +const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ + id: group.id, + name: group.display_name || group.name, + subtitle: getGroupSubtitle(group), + avatarUrl: group.avatar_url, +}); + +const memberPrincipal = ( + member: TypesGen.OrganizationMemberWithUserData, +): SharingPrincipal => ({ + id: member.user_id, + name: member.username, + subtitle: member.name || member.email || "User", + avatarUrl: member.avatar_url, +}); + +const sharingDialogData = ( + acl: TypesGen.ChatModelACL, + members: readonly TypesGen.OrganizationMemberWithUserData[], + groups: readonly TypesGen.Group[], +): SharingDialogData => ({ + acl: { + user_roles: { ...acl.user_roles }, + group_roles: { ...acl.group_roles }, + }, + principals: { + users: Object.fromEntries( + Object.keys(acl.user_roles).map((userId) => { + const member = members.find((item) => item.user_id === userId); + return [ + userId, + member + ? memberPrincipal(member) + : { id: userId, name: userId, subtitle: "User" }, + ]; + }), + ), + groups: Object.fromEntries( + Object.keys(acl.group_roles).map((groupId) => { + const group = groups.find((item) => item.id === groupId); + return [ + groupId, + group + ? groupPrincipal(group) + : { id: groupId, name: groupId, subtitle: "Group" }, + ]; + }), + ), + }, +}); + +const selectedPrincipal = ( + option: ChatModelPrincipal, +): SharingPrincipalSelection => + isGroup(option) + ? { kind: "group", principal: groupPrincipal(option) } + : { kind: "user", principal: memberPrincipal(option) }; + +type OpenChatModelSharingDialogProps = Omit< + ChatModelSharingDialogProps, + "open" +>; + +const OpenChatModelSharingDialog: FC = ({ onOpenChange, organizationId, modelId, modelName, }) => { const queryClient = useQueryClient(); - const [draft, setDraft] = useState(emptyDraft); - const [initialACL, setInitialACL] = useState(null); - const [initialized, setInitialized] = useState(false); - const [selectedOption, setSelectedOption] = - useState(null); - - const aclQuery = useQuery({ - ...chatModelACL(organizationId, modelId), - enabled: open && initialized, - }); + const aclOptions = chatModelACL(organizationId, modelId); + const membersOptions = organizationMembers(organizationId, { limit: 0 }); + const groupsOptions = groupsByOrganization(organizationId); + const aclQuery = useQuery({ ...aclOptions, refetchOnMount: "always" }); const membersQuery = useQuery({ - ...organizationMembers(organizationId, { limit: 0 }), - enabled: open, - }); - const groupsQuery = useQuery({ - ...groupsByOrganization(organizationId), - enabled: open, + ...membersOptions, + refetchOnMount: "always", }); + const groupsQuery = useQuery({ ...groupsOptions, refetchOnMount: "always" }); const updateMutation = useMutation(updateChatModelACL(queryClient)); - useEffect(() => { - if (!open || initialized) { - return; - } - let cancelled = false; - void aclQuery.refetch().then(({ data }) => { - if (cancelled || !data) { - return; - } - const snapshot = { - user_roles: { ...data.user_roles }, - group_roles: { ...data.group_roles }, - }; - setInitialACL(snapshot); - setDraft(snapshot); - setInitialized(true); - }); - return () => { - cancelled = true; - }; - }, [aclQuery.refetch, initialized, open]); + const members = membersQuery.data?.members; + const groups = groupsQuery.data; + const data = + aclQuery.data && members && groups + ? sharingDialogData(aclQuery.data, members, groups) + : undefined; + const loadError = data + ? null + : (aclQuery.error ?? + (members === undefined ? membersQuery.error : null) ?? + (groups === undefined ? groupsQuery.error : null)); + const refetchError = data + ? (aclQuery.error ?? membersQuery.error ?? groupsQuery.error) + : null; - const reset = () => { - setDraft(emptyDraft()); - setInitialACL(null); - setInitialized(false); - setSelectedOption(null); - updateMutation.reset(); - }; const close = () => { - reset(); onOpenChange(false); - }; - - const members = membersQuery.data?.members ?? []; - const groups = groupsQuery.data ?? []; - const userIds = Object.keys(draft.user_roles); - const groupIds = Object.keys(draft.group_roles); - const excludedPrincipals = [ - ...members - .filter((member) => userIds.includes(member.user_id)) - .map((member) => ({ id: member.user_id })), - ...groups.filter((group) => groupIds.includes(group.id)), - ]; - const loadError = - (!initialized ? aclQuery.error : null) ?? - (membersQuery.data === undefined ? membersQuery.error : null) ?? - (groupsQuery.data === undefined ? groupsQuery.error : null); - const refetchError = loadError - ? null - : (aclQuery.error ?? membersQuery.error ?? groupsQuery.error); - const isLoading = - !loadError && - ((open && !initialized) || membersQuery.isLoading || groupsQuery.isLoading); - const isEmpty = userIds.length === 0 && groupIds.length === 0; - const isDirty = - initialized && initialACL !== null && !isEqual(draft, initialACL); - - const addSelectedPrincipal = () => { - if (!selectedOption) { - return; - } - if (isGroup(selectedOption)) { - setDraft((current) => ({ - ...current, - group_roles: { ...current.group_roles, [selectedOption.id]: "read" }, - })); - } else { - setDraft((current) => ({ - ...current, - user_roles: { ...current.user_roles, [selectedOption.id]: "read" }, - })); - } - setSelectedOption(null); - }; - - const removeUser = (userId: string) => { - setDraft((current) => { - const userRoles = { ...current.user_roles }; - delete userRoles[userId]; - return { ...current, user_roles: userRoles }; + queryClient.removeQueries({ queryKey: aclOptions.queryKey, exact: true }); + queryClient.removeQueries({ + queryKey: membersOptions.queryKey, + exact: true, }); - }; - const removeGroup = (groupId: string) => { - setDraft((current) => { - const groupRoles = { ...current.group_roles }; - delete groupRoles[groupId]; - return { ...current, group_roles: groupRoles }; + queryClient.removeQueries({ + queryKey: groupsOptions.queryKey, + exact: true, }); }; - const save = () => { - if (!initialACL) { - return; - } - updateMutation.mutate( - { - organizationId, - modelId, - req: buildACLDelta(initialACL, draft), - }, - { - onSuccess: () => { - toast.success(`Sharing for "${modelName}" updated.`); - close(); - }, - }, - ); - }; - return ( - { - if (!nextOpen && !updateMutation.isPending) { - close(); - } - }} - > - - - Share model - - Choose which organization members and groups can use {modelName}. - - - - {updateMutation.error && } - {refetchError && } - {loadError && } - - {loadError ? null : isLoading ? ( -
- - Loading model sharing -
- ) : !initialized ? null : ( -
-
-
- -
- -
- - {isEmpty ? ( -
-

- No shared members or groups yet -

-

- Add a member or group using the controls above. -

-
- ) : ( - - - - Member - Role - - - - - {groupIds.map((groupId) => { - const group = groups.find((item) => item.id === groupId); - const name = group?.display_name || group?.name || groupId; - return ( - - - - - Read - - - - - ); - })} - {userIds.map((userId) => { - const member = members.find( - (item) => item.user_id === userId, - ); - const name = member?.username || userId; - return ( - - - - - Read - - - - - ); - })} - -
- )} -
- )} - - - - -
-
+ Choose which organization members and groups can use {modelName}. + } + loadingLabel="Loading model sharing" + tableLabel="Shared model members and groups" + data={data} + loadError={loadError} + refetchError={refetchError} + saveError={updateMutation.error} + isSaving={updateMutation.isPending} + readRole="read" + deletedRole="" + renderAutocomplete={({ value, onChange, excludedPrincipalIds }) => ( + ({ id }))} + className="w-full" + /> + )} + getPrincipal={selectedPrincipal} + onClose={close} + onSave={(req) => + updateMutation.mutate( + { organizationId, modelId, req }, + { + onSuccess: () => { + toast.success(`Sharing for "${modelName}" updated.`); + close(); + }, + }, + ) + } + /> ); }; + +export const ChatModelSharingDialog: FC = ( + props, +) => (props.open ? : null); diff --git a/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx b/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx new file mode 100644 index 00000000000..07bf8ce0ae2 --- /dev/null +++ b/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx @@ -0,0 +1,434 @@ +import isEqual from "lodash/isEqual"; +import { Trash2Icon, UserPlusIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogActions, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; + +type SharingACL = { + user_roles: Record; + group_roles: Record; +}; + +type SharingACLUpdate = { + user_roles?: Record; + group_roles?: Record; +}; + +export type SharingPrincipal = { + id: string; + name: string; + subtitle: string; + avatarUrl?: string; +}; + +type SharingPrincipals = { + users: Record; + groups: Record; +}; + +export type SharingDialogData = { + acl: SharingACL; + principals: SharingPrincipals; +}; + +export type SharingPrincipalSelection = { + kind: "user" | "group"; + principal: SharingPrincipal; +}; + +type SharingAutocompleteProps