Skip to content
5 changes: 3 additions & 2 deletions api/v1alpha1/codercontrolplane_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ type CoderControlPlaneSpec struct {
TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"`

// MCPServers configures CoderMCPServer objects that use this control
// plane. It has no effect yet: the CoderMCPServer controller is not
// shipped.
// plane. Removing it disables in Coder each server config that a
// CoderMCPServer created through this control plane. It has no effect
// yet: the CoderMCPServer controller is not shipped.
// +optional
MCPServers *MCPServersSpec `json:"mcpServers,omitempty"`

Expand Down
16 changes: 12 additions & 4 deletions api/v1alpha1/codermcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ type MCPServersSpec struct {
// control plane's operator token. This delegates Coder organization-admin
// power over MCP servers in these organizations to everyone who can
// create CoderMCPServer objects here. A CoderMCPServer for an
// organization outside this list sends no MCP request. Removing an
// organization stops further changes to its servers. Deleting a
// organization outside this list creates and changes nothing. Removing
// an organization disables in Coder each server config that a
// CoderMCPServer created there, with one request that holds only
// enabled: false, and then changes nothing else. After the organization
// is listed again, a spec edit enables the server. Deleting a
// CoderMCPServer still deletes the config it created.
//
// This field has no effect yet: the CoderMCPServer controller is not
Expand All @@ -45,7 +48,8 @@ type MCPServersSpec struct {
// AllowForceOn lets CoderMCPServer objects use availability: force_on,
// which adds a server to every new chat of each user who can read it,
// without that user's choice. When false (the default), such objects
// send no MCP request.
// create and change nothing, and a server config that one of them
// created is disabled in Coder, as for a removed organization.
//
// This field has no effect yet: the CoderMCPServer controller is not
// shipped.
Expand Down Expand Up @@ -293,7 +297,11 @@ type CoderMCPServerStatus struct {
// ObservedGeneration is the generation the controller last processed.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// AppliedGeneration is the last generation whose spec Coder fully holds.
// AppliedGeneration is the last generation whose spec Coder fully holds,
// or that a kill switch holds: when the controller disables the config
// because a credential Secret or the control plane withdrew consent, it
// sets this field before it sends the disable, so only a later spec edit
// enables the config again. Ready tells whether Coder holds the spec.
// +optional
AppliedGeneration int64 `json:"appliedGeneration,omitempty"`
// OrganizationID is the Coder organization ID that spec.organization
Expand Down
15 changes: 10 additions & 5 deletions config/crd/bases/coder.com_codercontrolplanes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1399,15 +1399,17 @@ spec:
mcpServers:
description: |-
MCPServers configures CoderMCPServer objects that use this control
plane. It has no effect yet: the CoderMCPServer controller is not
shipped.
plane. Removing it disables in Coder each server config that a
CoderMCPServer created through this control plane. It has no effect
yet: the CoderMCPServer controller is not shipped.
properties:
allowForceOn:
description: |-
AllowForceOn lets CoderMCPServer objects use availability: force_on,
which adds a server to every new chat of each user who can read it,
without that user's choice. When false (the default), such objects
send no MCP request.
create and change nothing, and a server config that one of them
created is disabled in Coder, as for a removed organization.

This field has no effect yet: the CoderMCPServer controller is not
shipped.
Expand All @@ -1419,8 +1421,11 @@ spec:
control plane's operator token. This delegates Coder organization-admin
power over MCP servers in these organizations to everyone who can
create CoderMCPServer objects here. A CoderMCPServer for an
organization outside this list sends no MCP request. Removing an
organization stops further changes to its servers. Deleting a
organization outside this list creates and changes nothing. Removing
an organization disables in Coder each server config that a
CoderMCPServer created there, with one request that holds only
enabled: false, and then changes nothing else. After the organization
is listed again, a spec edit enables the server. Deleting a
CoderMCPServer still deletes the config it created.

This field has no effect yet: the CoderMCPServer controller is not
Expand Down
8 changes: 6 additions & 2 deletions config/crd/dormant/coder.com_codermcpservers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,12 @@ spec:
description: CoderMCPServerStatus defines the observed state of a CoderMCPServer.
properties:
appliedGeneration:
description: AppliedGeneration is the last generation whose spec Coder
fully holds.
description: |-
AppliedGeneration is the last generation whose spec Coder fully holds,
or that a kill switch holds: when the controller disables the config
because a credential Secret or the control plane withdrew consent, it
sets this field before it sends the disable, so only a later spec edit
enables the config again. Ready tells whether Coder holds the spec.
format: int64
type: integer
conditions:
Expand Down
15 changes: 10 additions & 5 deletions dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2061,15 +2061,17 @@ spec:
mcpServers:
description: |-
MCPServers configures CoderMCPServer objects that use this control
plane. It has no effect yet: the CoderMCPServer controller is not
shipped.
plane. Removing it disables in Coder each server config that a
CoderMCPServer created through this control plane. It has no effect
yet: the CoderMCPServer controller is not shipped.
properties:
allowForceOn:
description: |-
AllowForceOn lets CoderMCPServer objects use availability: force_on,
which adds a server to every new chat of each user who can read it,
without that user's choice. When false (the default), such objects
send no MCP request.
create and change nothing, and a server config that one of them
created is disabled in Coder, as for a removed organization.

This field has no effect yet: the CoderMCPServer controller is not
shipped.
Expand All @@ -2081,8 +2083,11 @@ spec:
control plane's operator token. This delegates Coder organization-admin
power over MCP servers in these organizations to everyone who can
create CoderMCPServer objects here. A CoderMCPServer for an
organization outside this list sends no MCP request. Removing an
organization stops further changes to its servers. Deleting a
organization outside this list creates and changes nothing. Removing
an organization disables in Coder each server config that a
CoderMCPServer created there, with one request that holds only
enabled: false, and then changes nothing else. After the organization
is listed again, a spec edit enables the server. Deleting a
CoderMCPServer still deletes the config it created.

This field has no effect yet: the CoderMCPServer controller is not
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/api/codercontrolplane.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
| `tolerations` | [Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#toleration-v1-core) array | Tolerations are applied to the control plane pod. |
| `affinity` | [Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#affinity-v1-core) | Affinity configures pod affinity/anti-affinity rules. |
| `topologySpreadConstraints` | [TopologySpreadConstraint](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#topologyspreadconstraint-v1-core) array | TopologySpreadConstraints control pod topology spread. |
| `mcpServers` | [MCPServersSpec](#mcpserversspec) | MCPServers configures CoderMCPServer objects that use this control plane. It has no effect yet: the CoderMCPServer controller is not shipped. |
| `mcpServers` | [MCPServersSpec](#mcpserversspec) | MCPServers configures CoderMCPServer objects that use this control plane. Removing it disables in Coder each server config that a CoderMCPServer created through this control plane. It has no effect yet: the CoderMCPServer controller is not shipped. |
| `aiGateway` | [AIGatewaySpec](#aigatewayspec) | AIGateway configures CoderAIGatewayKey objects that use this control plane. |
| `workspacePools` | [WorkspacePoolsSpec](#workspacepoolsspec) | WorkspacePools configures CoderWorkspacePool objects that use this control plane. It has no effect yet: the CoderWorkspacePool controller is not shipped. |
| `templateTests` | [TemplateTestsSpec](#templatetestsspec) | TemplateTests configures CoderTemplateTest runs against this control plane. |
Expand Down Expand Up @@ -157,8 +157,8 @@ CoderControlPlane (CoderControlPlane.spec.mcpServers).

| Field | Type | Description |
| --- | --- | --- |
| `organizations` | string array | Organizations are the names of the Coder organizations whose MCP server configs CoderMCPServer objects can create, change and delete with this control plane's operator token. This delegates Coder organization-admin power over MCP servers in these organizations to everyone who can create CoderMCPServer objects here. A CoderMCPServer for an organization outside this list sends no MCP request. Removing an organization stops further changes to its servers. Deleting a CoderMCPServer still deletes the config it created. This field has no effect yet: the CoderMCPServer controller is not shipped. |
| `allowForceOn` | boolean | AllowForceOn lets CoderMCPServer objects use availability: force_on, which adds a server to every new chat of each user who can read it, without that user's choice. When false (the default), such objects send no MCP request. This field has no effect yet: the CoderMCPServer controller is not shipped. |
| `organizations` | string array | Organizations are the names of the Coder organizations whose MCP server configs CoderMCPServer objects can create, change and delete with this control plane's operator token. This delegates Coder organization-admin power over MCP servers in these organizations to everyone who can create CoderMCPServer objects here. A CoderMCPServer for an organization outside this list creates and changes nothing. Removing an organization disables in Coder each server config that a CoderMCPServer created there, with one request that holds only enabled: false, and then changes nothing else. After the organization is listed again, a spec edit enables the server. Deleting a CoderMCPServer still deletes the config it created. This field has no effect yet: the CoderMCPServer controller is not shipped. |
| `allowForceOn` | boolean | AllowForceOn lets CoderMCPServer objects use availability: force_on, which adds a server to every new chat of each user who can read it, without that user's choice. When false (the default), such objects create and change nothing, and a server config that one of them created is disabled in Coder, as for a removed organization. This field has no effect yet: the CoderMCPServer controller is not shipped. |

### OperatorAccessSpec

Expand Down
52 changes: 41 additions & 11 deletions internal/controller/codermcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,32 @@ func (r *CoderMCPServerReconciler) check(
return mcpServerWait(mcpServerRetry, wait.reason, "%s", wait.message), nil
}
org, step, err := resolveMCPServerOrganization(ctx, sdk, server.Spec.Organization)
if step != nil || err != nil {
return step, err
if err != nil {
return nil, err
}
pinned := server.Status.OrganizationID
if step == nil && pinned != "" && pinned != org.ID.String() {
step = mcpServerStalled(mcpServerResync, "OrganizationChanged",
"Coder organization %s now has ID %s, not the pinned ID %s. Re-create this object to use the new organization.", server.Spec.Organization, org.ID, pinned)
}
if pinned := server.Status.OrganizationID; pinned != "" && pinned != org.ID.String() {
return mcpServerStalled(mcpServerResync, "OrganizationChanged",
"Coder organization %s now has ID %s, not the pinned ID %s. Re-create this object to use the new organization.", server.Spec.Organization, org.ID, pinned), nil
if step != nil {
// The config of a renamed, deleted or replaced organization stays in
// the pinned organization, and a withdrawn opt-in still disables it.
if pinnedID, parseErr := uuid.Parse(pinned); (step.reason == "OrganizationNotFound" || step.reason == "OrganizationChanged") && parseErr == nil && server.Status.ConfigID != "" {
if optInStep, err := r.optInGate(ctx, sdk, controlPlane, server, before, pinnedID); optInStep != nil || err != nil {
return optInStep, err
}
}
return step, nil
}
server.Status.OrganizationID = org.ID.String()
if step, err := mcpServerScopeGate(ctx, sdk, controlPlane, server.Spec, org.ID); step != nil || err != nil {
if step, err := r.optInGate(ctx, sdk, controlPlane, server, before, org.ID); step != nil || err != nil {
return step, err
}
values, step, err := readMCPServerCredentials(ctx, r.Client, server)
if step != nil && step.stalled && server.Status.ConfigID != "" {
// Fail closed when consent goes (plan G8).
step, err = r.disableMCPServerWithoutConsent(ctx, sdk, server, org.ID, step)
step, err = r.disableMCPServerWithoutConsent(ctx, sdk, server, before, org.ID, step)
}
if step != nil || err != nil {
return step, err
Expand Down Expand Up @@ -258,12 +269,29 @@ func (r *CoderMCPServerReconciler) check(
return step, nil
}

// optInGate applies the control plane's opt-in for the organization orgID.
// When the opt-in is withdrawn for a config that this object created, it
// disables the config (#336). Any other stall of the scope check, such as a
// mismatched Coder answer, keeps the config as it is.
func (r *CoderMCPServerReconciler) optInGate(
ctx context.Context, sdk *codersdk.Client, controlPlane *coderv1alpha1.CoderControlPlane,
server *coderv1alpha1.CoderMCPServer, before *coderv1alpha1.CoderMCPServerStatus, orgID uuid.UUID,
) (*mcpServerStep, error) {
step, err := mcpServerScopeGate(ctx, sdk, controlPlane, server.Spec, orgID)
if step != nil && (step.reason == "MCPServersNotAllowed" || step.reason == "ForceOnNotAllowed") && server.Status.ConfigID != "" {
return r.disableMCPServerWithoutOptIn(ctx, sdk, server, before, orgID, step)
}
return step, err
}

// resolveMCPServerOrganization looks up a Coder organization by name. Coder
// compares names without case.
// compares names without case. Only Coder's own 404 proves that the
// organization does not exist: another 404 (a proxy page, "Route not
// found.") waits like any failed read (#358).
func resolveMCPServerOrganization(ctx context.Context, sdk *codersdk.Client, name string) (codersdk.Organization, *mcpServerStep, error) {
org, err := sdk.OrganizationByName(ctx, name)
switch {
case isCoderNotFound(err):
case isCoderResourceNotFound(err):
return org, mcpServerWait(mcpServerResync, "OrganizationNotFound", "Coder organization %s does not exist.", name), nil
case err != nil:
return org, mcpServerWait(mcpServerRetry, "CoderUnavailable", "get organization %s: %s", name, coderErrorSummary(err)), nil
Expand All @@ -286,7 +314,7 @@ func mcpServerScopeGate(
) (*mcpServerStep, error) {
optIn := controlPlane.Spec.MCPServers
if optIn == nil {
return mcpServerStalled(0, "MCPServersNotAllowed", "CoderControlPlane %s does not set spec.mcpServers.", controlPlane.Name), nil
return mcpServerStalled(mcpServerResync, "MCPServersNotAllowed", "CoderControlPlane %s does not set spec.mcpServers.", controlPlane.Name), nil
}
allowed := false
for _, name := range optIn.Organizations {
Expand All @@ -310,7 +338,9 @@ func mcpServerScopeGate(
}
}
if !allowed {
return mcpServerStalled(0, "MCPServersNotAllowed",
// A listed name can resolve later, when it is created or renamed in
// Coder, and no Kubernetes event follows (#358).
return mcpServerStalled(mcpServerResync, "MCPServersNotAllowed",
"CoderControlPlane %s does not list Coder organization %s in spec.mcpServers.organizations.", controlPlane.Name, spec.Organization), nil
}
if spec.Availability == "force_on" && !optIn.AllowForceOn {
Expand Down
17 changes: 12 additions & 5 deletions internal/controller/codermcpserver_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,17 @@ func TestCoderMCPServerControlPlaneAndOptIn(t *testing.T) {
require.NoError(t, k8sClient.Update(ctx, server))
mcp := env.mcpRequests()
got, _ = env.reconcile(ctx, t, server)
requireMCPServerStep(t, got, true, "ForceOnNotAllowed")
require.Equal(t, mcp, env.mcpRequests())
// The config exists, so the withdrawn opt-in disables it (#336): one read
// and one PATCH.
require.Contains(t, requireMCPServerStep(t, got, true, "ForceOnNotAllowed"), "The controller disabled")
require.Equal(t, mcp+2, env.mcpRequests())
env.setOptIn(ctx, t, &coderv1alpha1.MCPServersSpec{Organizations: []string{"coder"}, AllowForceOn: true})
got, _ = env.reconcile(ctx, t, server)
requireMCPServerStep(t, got, true, "DisabledExternally")
require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(server), server))
server.Spec.Description = "re-armed"
require.NoError(t, k8sClient.Update(ctx, server))
got, _ = env.reconcile(ctx, t, server)
requireMCPServerStep(t, got, false, "Applied")
}

Expand Down Expand Up @@ -586,8 +593,8 @@ func TestCoderMCPServerWatches(t *testing.T) {
}, 30*time.Second, 100*time.Millisecond, reason)
}
requireReason("Applied")
// A withdrawn consent disables the config in Coder (plan G8), so only a
// spec edit applies it again.
// A withdrawn consent (plan G8) or opt-in (#336) disables the config in
// Coder, so only a spec edit applies it again.
rearm := func(description string) {
t.Helper()
requireReason("DisabledExternally")
Expand All @@ -609,7 +616,7 @@ func TestCoderMCPServerWatches(t *testing.T) {
env.setOptIn(ctx, t, nil)
requireReason("MCPServersNotAllowed")
env.setOptIn(ctx, t, &coderv1alpha1.MCPServersSpec{Organizations: []string{"coder"}})
requireReason("Applied")
rearm("opt-in back")

// Another control plane that uses a credential Secret for its own access
// makes it unusable. Deleting that control plane makes it usable again.
Expand Down
Loading
Loading