Files
coder/codersdk/mcp.go
T
Michael Suchacz 7ca7c30f40 feat: add group and user ACLs to MCP server configs (#27944)
Adds group and user ACLs to org-scoped MCP server configs so
organizations can restrict specific MCP servers to subsets of members,
mirroring the template ACL pattern.

## Summary

- Migration adds `group_acl`/`user_acl` JSONB columns (nested `{"<id>":
{"permissions": [...]}}` shape) and seeds every existing config with its
organization's Everyone group read entry (the Everyone group ID equals
the org ID), so member access is unchanged by default. Creation seeds
the same entry.
- The blanket org-member read grant from the base PR is replaced by ACL
evaluation: Rego requires org membership for every ACL grant, and
`GetAuthorizedMCPServerConfigs` compiles ACL-aware SQL filters.
- New `ActionShare` (org admins) gates `GET|PATCH
/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl`
(nested under the organization like the rest of the config surface).
PATCH validates principals against the config's organization, merges
sparse updates under a row lock, stamps `updated_by`/`updated_at`, and
is audited as a Write with `Old` captured before authorization. A config
deleted concurrently between the middleware fetch and the locked
re-fetch is concealed as 404, matching the update and delete handlers.
ACL columns are tracked in the audit table.
- ACL management is available in all editions (no enterprise
entitlement), documented in the MCP servers page. Revoking an ACL does
not retro-strip already-selected configs from existing chats; new
selection is blocked at chat create.
- Force On respects the ACL: the forced set is loaded as the chat owner,
so a `force_on` server whose ACL denies the owner never attaches at
create, send, or generation time.
- No rolling-upgrade machinery: upgrades run in scheduled maintenance
downtime, so the migration only backfills existing rows; the API sets
the Everyone read grant explicitly on every insert.

Stacked on #27943. Part of the MCP org-separation stack.

Closes https://linear.app/codercom/issue/CODAGT-712

UAT: verified on a dogfood instance with two members and a custom group:
Everyone-seed default visibility, group grant with Everyone removal
(non-member loses list/fetch/selection), foreign-principal rejection,
non-admin share denial (audited 403 / concealed 404), user_acl restore,
and audited ACL diffs.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 19:25:18 +00:00

306 lines
13 KiB
Go

package codersdk
import (
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
// MCPServerOAuth2ConnectURL returns the URL the user should visit to
// start the OAuth2 flow for an MCP server. The frontend opens this
// in a new window/popup.
func (c *Client) MCPServerOAuth2ConnectURL(organizationID, id uuid.UUID) string {
return fmt.Sprintf("%s/api/experimental/organizations/%s/mcp-servers/%s/oauth2/connect", c.URL.String(), organizationID, id)
}
// MCPServerOAuth2DisconnectResponse reports whether the removed token
// was also revoked at the OAuth provider.
type MCPServerOAuth2DisconnectResponse struct {
TokenRevoked bool `json:"token_revoked"`
TokenRevocationError string `json:"token_revocation_error,omitempty"`
}
// MCPServerOAuth2Disconnect removes the user's OAuth2 token for an
// MCP server. Use MCPServerOAuth2DisconnectWithResponse for the
// provider revocation outcome.
func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) error {
_, err := c.MCPServerOAuth2DisconnectWithResponse(ctx, id)
return err
}
// MCPServerOAuth2DisconnectWithResponse removes the user's OAuth2
// token for an MCP server and reports the provider revocation outcome.
func (c *Client) MCPServerOAuth2DisconnectWithResponse(ctx context.Context, id uuid.UUID) (MCPServerOAuth2DisconnectResponse, error) {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", id), nil)
if err != nil {
return MCPServerOAuth2DisconnectResponse{}, err
}
defer res.Body.Close()
// Servers from before provider revocation respond 204 without a body.
if res.StatusCode == http.StatusNoContent {
return MCPServerOAuth2DisconnectResponse{}, nil
}
if res.StatusCode != http.StatusOK {
return MCPServerOAuth2DisconnectResponse{}, ReadBodyAsError(res)
}
var resp MCPServerOAuth2DisconnectResponse
return resp, ReadBodyAsJSON(res, &resp)
}
// MCPServerConfig represents an admin-configured MCP server.
type MCPServerConfig struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
Transport string `json:"transport"` // "streamable_http" or "sse"
URL string `json:"url"`
AuthType string `json:"auth_type"` // "none", "oauth2", "api_key", "custom_headers", "user_oidc"
// OAuth2 fields (only populated for admins).
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
HasOAuth2Secret bool `json:"has_oauth2_secret"`
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty"`
OAuth2TokenURL string `json:"oauth2_token_url,omitempty"`
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty"`
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
// API key fields (only populated for admins).
APIKeyHeader string `json:"api_key_header,omitempty"`
HasAPIKey bool `json:"has_api_key"`
HasCustomHeaders bool `json:"has_custom_headers"`
// Tool governance.
ToolAllowList []string `json:"tool_allow_list"`
ToolDenyList []string `json:"tool_deny_list"`
// Availability policy set by admin.
Availability string `json:"availability"` // "force_on", "default_on", "default_off"
Enabled bool `json:"enabled"`
ModelIntent bool `json:"model_intent"`
AllowInPlanMode bool `json:"allow_in_plan_mode"`
// ForwardCoderHeaders forwards the same Coder identity headers we
// send to LLM providers (X-Coder-Owner-Id, X-Coder-Chat-Id, and the
// optional X-Coder-Subchat-Id and X-Coder-Workspace-Id) to this
// MCP server on every request. Off by default to avoid leaking
// chat identity to third-party servers.
ForwardCoderHeaders bool `json:"forward_coder_headers"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
// Per-user state (populated for non-admin requests).
AuthConnected bool `json:"auth_connected"`
}
// MCPServerConfigRole is a role a user or group holds in an MCP server
// config's access control list.
type MCPServerConfigRole string
const (
MCPServerConfigRoleRead MCPServerConfigRole = "read"
// MCPServerConfigRoleDeleted removes the principal's ACL entry when
// used in an update request.
MCPServerConfigRoleDeleted MCPServerConfigRole = ""
)
// MCPServerConfigACL is the resolved access control list of an MCP server
// config.
type MCPServerConfigACL struct {
Users []MCPServerConfigUser `json:"users"`
Groups []MCPServerConfigGroup `json:"groups"`
}
// MCPServerConfigUser is a user entry in an MCP server config ACL.
type MCPServerConfigUser struct {
MinimalUser
Role MCPServerConfigRole `json:"role" enums:"read"`
}
// MCPServerConfigGroup is a group entry in an MCP server config ACL.
type MCPServerConfigGroup struct {
Group
Role MCPServerConfigRole `json:"role" enums:"read"`
}
// UpdateMCPServerConfigACLRequest is a sparse update of an MCP server
// config ACL: only the listed principals change, and
// MCPServerConfigRoleDeleted removes an entry.
type UpdateMCPServerConfigACLRequest struct {
UserRoles map[string]MCPServerConfigRole `json:"user_roles,omitempty"`
GroupRoles map[string]MCPServerConfigRole `json:"group_roles,omitempty"`
}
// CreateMCPServerConfigRequest is the request to create a new MCP server config.
type CreateMCPServerConfigRequest struct {
DisplayName string `json:"display_name" validate:"required"`
Slug string `json:"slug" validate:"required"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
Transport string `json:"transport" validate:"required,oneof=streamable_http sse"`
URL string `json:"url" validate:"required,url"`
AuthType string `json:"auth_type" validate:"required,oneof=none oauth2 api_key custom_headers user_oidc"`
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
OAuth2ClientSecret string `json:"oauth2_client_secret,omitempty"`
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
OAuth2TokenURL string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
// OAuth2RevocationURL is the provider's RFC 7009 revocation
// endpoint; auto-populated by OAuth2 discovery when omitted.
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty" validate:"omitempty,url"`
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
APIKeyHeader string `json:"api_key_header,omitempty"`
APIKeyValue string `json:"api_key_value,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
ToolAllowList []string `json:"tool_allow_list,omitempty"`
ToolDenyList []string `json:"tool_deny_list,omitempty"`
Availability string `json:"availability" validate:"required,oneof=force_on default_on default_off"`
Enabled bool `json:"enabled"`
ModelIntent bool `json:"model_intent"`
AllowInPlanMode bool `json:"allow_in_plan_mode"`
// ForwardCoderHeaders, when true, forwards Coder identity
// headers on every outgoing MCP request. See MCPServerConfig.
ForwardCoderHeaders bool `json:"forward_coder_headers"`
}
// UpdateMCPServerConfigRequest is the request to update an MCP server config.
type UpdateMCPServerConfigRequest struct {
DisplayName *string `json:"display_name,omitempty"`
Slug *string `json:"slug,omitempty"`
Description *string `json:"description,omitempty"`
IconURL *string `json:"icon_url,omitempty"`
Transport *string `json:"transport,omitempty" validate:"omitempty,oneof=streamable_http sse"`
URL *string `json:"url,omitempty" validate:"omitempty,url"`
AuthType *string `json:"auth_type,omitempty" validate:"omitempty,oneof=none oauth2 api_key custom_headers user_oidc"`
OAuth2ClientID *string `json:"oauth2_client_id,omitempty"`
OAuth2ClientSecret *string `json:"oauth2_client_secret,omitempty"`
OAuth2AuthURL *string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
OAuth2TokenURL *string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
// OAuth2RevocationURL is validated in the handler because a
// validate tag would reject the pointer to "" that clears it.
OAuth2RevocationURL *string `json:"oauth2_revocation_url,omitempty"`
OAuth2Scopes *string `json:"oauth2_scopes,omitempty"`
APIKeyHeader *string `json:"api_key_header,omitempty"`
APIKeyValue *string `json:"api_key_value,omitempty"`
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
ToolAllowList *[]string `json:"tool_allow_list,omitempty"`
ToolDenyList *[]string `json:"tool_deny_list,omitempty"`
Availability *string `json:"availability,omitempty" validate:"omitempty,oneof=force_on default_on default_off"`
Enabled *bool `json:"enabled,omitempty"`
ModelIntent *bool `json:"model_intent,omitempty"`
AllowInPlanMode *bool `json:"allow_in_plan_mode,omitempty"`
// ForwardCoderHeaders, when set, updates whether Coder identity
// headers are forwarded on every outgoing MCP request.
ForwardCoderHeaders *bool `json:"forward_coder_headers,omitempty"`
}
func (c *Client) MCPServerConfigs(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", organizationID), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var configs []MCPServerConfig
return configs, ReadBodyAsJSON(res, &configs)
}
func (c *Client) MCPServerConfigByID(ctx context.Context, organizationID, id uuid.UUID) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s", organizationID, id), nil)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, ReadBodyAsJSON(res, &config)
}
// MCPServerConfigACL returns the resolved ACL of an MCP server config.
func (c *Client) MCPServerConfigACL(ctx context.Context, organizationID, id uuid.UUID) (MCPServerConfigACL, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s/acl", organizationID, id), nil)
if err != nil {
return MCPServerConfigACL{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return MCPServerConfigACL{}, ReadBodyAsError(res)
}
var acl MCPServerConfigACL
return acl, ReadBodyAsJSON(res, &acl)
}
// 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 {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s/acl", organizationID, id), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) CreateMCPServerConfig(ctx context.Context, organizationID uuid.UUID, req CreateMCPServerConfigRequest) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", organizationID), req)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, ReadBodyAsJSON(res, &config)
}
func (c *Client) UpdateMCPServerConfig(ctx context.Context, organizationID, id uuid.UUID, req UpdateMCPServerConfigRequest) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s", organizationID, id), req)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, ReadBodyAsJSON(res, &config)
}
func (c *Client) DeleteMCPServerConfig(ctx context.Context, organizationID, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s", organizationID, id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}