Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0c3b11a
feat(coderd): add MCP ACL candidates endpoint
ibetitsmike Aug 25, 2026
fc5da1a
feat(site): add MCP server ACL sharing plumbing
ibetitsmike Aug 25, 2026
661b361
feat(site): add MCP server sharing dialog
ibetitsmike Aug 25, 2026
ab5d87e
feat(site): wire MCP server sharing controls
ibetitsmike Aug 25, 2026
8233626
test(site): stabilize MCP share access story
ibetitsmike Aug 25, 2026
52a7ecb
refactor(site): narrow MCP permission query keys
ibetitsmike Aug 25, 2026
5f4a1ed
refactor(site): reuse typed query keys in MCP ACL queries
ibetitsmike Aug 25, 2026
82b68dc
fix(site): surface AI settings navigation for MCP share-only users
ibetitsmike Aug 25, 2026
115f978
test(site): cover share-only MCP navigation with interaction stories
ibetitsmike Aug 25, 2026
ff0a96b
fix(coderd): keep share-only MCP detail route contract
ibetitsmike Aug 25, 2026
220826f
fix(site): surface MCP permission lookup failure in AI settings redirect
ibetitsmike Aug 25, 2026
15e5b07
refactor(site): share ACL dialog implementation
ibetitsmike Aug 25, 2026
395e0ab
Merge remote-tracking branch 'origin/main' into mike/acl-sharing-a8yw
ibetitsmike Aug 26, 2026
3a04800
chore: tighten ACL sharing comments
ibetitsmike Aug 26, 2026
33c6eab
fix: align MCP sharing permissions
ibetitsmike Aug 26, 2026
ac1ba20
chore: merge main into MCP ACL sharing
ibetitsmike Aug 26, 2026
5795281
Merge remote-tracking branch 'origin/main' into mike/acl-sharing-a8yw
ibetitsmike Aug 26, 2026
58855aa
fix(coderd): limit ACL candidates route to v2
ibetitsmike Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion coderd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
ibetitsmike marked this conversation as resolved.
httpapi.ResourceNotFound(rw)
return
}
Expand Down
169 changes: 151 additions & 18 deletions coderd/mcp_acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package coderd

import (
"context"
"database/sql"
"fmt"
"maps"
"net/http"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Comment thread
ibetitsmike marked this conversation as resolved.
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
Expand Down
Loading
Loading