Skip to content

Commit 5cfffaa

Browse files
authored
🤖 feat: activate the CoderMCPServer controller (#381)
## Summary The `CoderMCPServer` controller now runs. A `CoderMCPServer` creates and manages one MCP server config in a Coder organization, which Coder chats offer to the model. Before this PR the kind was dormant: its controller was not registered and its CRD was not installed. This is plan step M9 of #155. Steps M1 to M8 landed in earlier PRs, the last ones as #368 (delete path), #371 (E2E stub), #376 (test S9) and #377 (the opt-in `/status` admission policy). ## What changes 1. `internal/app/controllerapp/controllerapp.go` registers `CoderMCPServerReconciler`. 2. The CRD moves from `config/crd/dormant/` to `config/crd/bases/`, into `config/default/kustomization.yaml` and `dist/install.yaml`. `hack/update-manifests.sh` drops it from `DORMANT_CRDS`, and the CI dormancy guard step for the kind goes. 3. RBAC markers: `get`, `list`, `watch`, `patch` on `codermcpservers`, `get` and `update` on `codermcpservers/status`, `update` on `codermcpservers/finalizers`. The `secrets` rule does not change (test S9, #376, passes). 4. A sample with its credential Secret: `config/samples/coder_v1alpha1_codermcpserver.yaml`. 5. The how-to `docs/how-to/manage-mcp-servers.md`, plus the nav, the index, the architecture page, the controller deploy page and the getting-started cleanup. 6. The API reference page `docs/reference/api/codermcpserver.md`. The "no effect yet" sentences leave the `spec.mcpServers` field docs of `CoderControlPlane` and the `CoderMCPServer` types. 7. The E2E path filter gains `internal/controller/codermcpserver*`. ## What the how-to documents - The control plane opt-in (`spec.mcpServers.organizations`, `allowForceOn`): organization names resolve to IDs, and a withdrawn opt-in disables each affected server with one `enabled: false` update and no credential read. The object then reports `DisabledExternally` until a spec edit. - The credential Secret contract (label, destinations annotation, value rules), and that Coder sends the credential again after a redirect. - Access: `spec.access` lists groups only, so every sync removes user grants added in the dashboard. With `access` omitted, Coder's default lets the organization's Everyone group read the server. - The #313 fail-closed behavior on a missing group: remove unnamed entries, add nothing, send `enabled: false` if the generation disables the server, and add the groups once they exist. - The #365 disable window and its known limit. - Rotation, URL changes (and their order), dashboard kill switches, the credential kill switch, the emergency path, and deletion (from #368). - The Coder-side limits (plan 5.5): access checked only at chat selection, tool lists by exact name, OAuth2 tokens deleted on a URL change, encryption at rest only with enterprise keys, no address guard. - RBAC guidance: what each permission means in Coder, a viewer Role, an editor Role, the wildcard warning, a `ResourceQuota` note. - `config/admission/`: opt-in (the install bundle does not apply it), needs `ValidatingAdmissionPolicy` (GA in Kubernetes 1.30), and hard-codes the ServiceAccount `system:serviceaccount:coder-system:coder-k8s`. Edit it when the operator runs as another ServiceAccount. ## Dogfood A full run of plan section 8 on a private Kind cluster (kind v0.26.0, kindest/node v1.32.0, Coder v2.37.2, no license), with images built from `091c7fcf`. That commit has the same controller code as this head. Later commits changed only docs, the CI path filter, and moved the GitOps health rules out. Every step passed: | Step | Result | |---|---| | Opt-in, Secret, apply | `Ready` 233 ms after apply. Coder holds the slug, URL, `enabled`, `has_api_key`, and an ACL of Everyone read only. The key appears 0 times in the CR JSON and the operator logs. | | Dashboard and chat | The server shows in **AI settings > MCP servers**. A chat that selects it gets the stub's answer. The stub got a matching key hash, and the LLM got `e2e-stub__alpha`, `e2e-stub__beta` and `e2e-stub__Gamma`. | | Rotation | Coder got the new key 99 ms after the Secret change, and the next turn showed the new hash. An annotation-only change left `updated_at` unchanged. | | Refusals (P4 a to f) | `CredentialNotOptedIn`, `CredentialRefused`, `CredentialDestinationNotAllowed`, `SlugConflict` (the other config unchanged), `MCPServersNotAllowed`, `ForceOnNotAllowed`. No Coder config for a, b, c, e, f. | | Kill switch | A dashboard disable gives `DisabledExternally`, and Coder stays disabled. A `displayName` edit enables it again. | | URL change, both orders | The chat reached only the intended Service, with a matching key hash. | | Access | `groups: []`: a member's chat gets 400. `[Everyone]`: 201. | | Admission policy | Status patches by an impersonated `system:masters` user and by the Kind admin are denied with the policy message. The controller still reconciles. | | Delete, namespace delete | Coder answers 404 after the delete. In a namespace deletion, a second CR releases with `ControlPlaneGone` (7 s), and the namespace goes (17 s). | For P4 (e), unlicensed Coder allows one organization, so the run listed `[no-such-org]` instead. The dogfood found one how-to mismatch (the reason after a restored opt-in), which this PR fixes. The screenshots and the recording are in the evidence comment below. ## Validation - Local gates on this head: `make verify-vendor`, `make build`, `make lint`, `GOFLAGS=-mod=vendor go tool govulncheck ./...`, `make test`, `make test-integration`, `go test -race ./internal/controller/`, and `make docs-check` pass. `make codegen`, `make manifests`, `make build-installer` and `make docs-reference` leave no diff. actionlint passes. - `TestSetupControllersStartsEveryController` (S18) now also needs a `CoderMCPServer` to get its finalizer and `ControlPlaneNotReady`, which proves the registration. A mutation that removes the registration fails it. - A new case in `TestCoderMCPServerControlPlaneAndOptIn`: a listed name that resolves to the object's organization ID is allowed (IDs are compared, not names). A mutation to a name compare fails it. - `bash ./hack/e2e-workspace-lifecycle_test.sh` times out locally under host load, also on unchanged `main`. CI runs it. ## Size 451 hand-written changed lines (300 of them docs) and 740 generated (`dist/install.yaml`, the CRDs, `role.yaml`, the kustomization, the reference pages). ## Follow-ups - The Argo CD and Flux health rules for `CoderMCPServer` come in the next PR, as #212 did for `CoderTemplateTest`. - #362 (part 1 of #353) holds a deleted `CoderControlPlane` while `CoderAIGatewayKey` and `CoderAIProvider` objects still use it. It does not cover `CoderMCPServer`. A control plane deletion can therefore leave MCP server configs in Coder. The how-to says to delete `CoderMCPServer` objects first. Follow-up to #353: add `CoderMCPServer` to `controlPlaneDependents`. - #372: an ownership proof for Coder IDs in controller status (plan D11 B). - #378: delete path and RBAC test follow-ups from the M7, S9 and S11 assessments. - Each new CR logs one "Operation cannot be fulfilled" error when it adds its finalizer. The controller retries it with no effect. - Plan step M10 adds the Kind E2E phases, and M11 the tool lists. #155 stays open until then. Refs #155 --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$220.71`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=220.71 --> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 6dd8a6f commit 5cfffaa

24 files changed

Lines changed: 1116 additions & 75 deletions

‎.cspell.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
"stakater",
5252
"coderaigatewaykey",
5353
"coderaigatewaykeys",
54+
"codermcpserver",
55+
"codermcpservers",
5456
"coderaiprovider",
5557
"coderaiproviders",
5658
"codertemplatetest",

‎.github/workflows/ci.yaml‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ jobs:
8787
- 'internal/controller/codertemplatetest*'
8888
- 'internal/controller/coderaiprovider*'
8989
- 'internal/controller/coderaigatewaykey*'
90+
- 'internal/controller/codermcpserver*'
9091
- 'internal/controller/owned_secret.go'
9192
- 'internal/controller/reserved_secret_names.go'
9293
- 'internal/controller/credential_checks.go'
@@ -172,22 +173,6 @@ jobs:
172173
exit 1
173174
fi
174175
175-
- name: Verify the dormant CoderMCPServer CRD is not installed
176-
# Remove this step when the CoderMCPServer controller ships (#155).
177-
run: |
178-
# grep exits 1 when nothing matches. Any other status (for example a missing file) fails the step.
179-
status=0
180-
# The third pattern matches the CRD path entry in config/default/kustomization.yaml.
181-
grep -n -E '^[[:space:]]+kind: CoderMCPServer$|codermcpservers\.coder\.com|coder\.com_codermcpservers\.yaml' \
182-
dist/install.yaml config/default/kustomization.yaml config/crd/bases/*.yaml || status=$?
183-
if [[ "${status}" -eq 0 ]]; then
184-
echo "assertion failed: the CoderMCPServer CRD must not ship before its controller" >&2
185-
exit 1
186-
elif [[ "${status}" -ne 1 ]]; then
187-
echo "assertion failed: the CoderMCPServer CRD guard could not read the install manifests (grep exit ${status})" >&2
188-
exit 1
189-
fi
190-
191176
- name: Verify the dormant CoderWorkspacePool CRD is not installed
192177
# Remove this step when the CoderWorkspacePool controller ships (#153).
193178
run: |

‎api/v1alpha1/codercontrolplane_types.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,7 @@ type CoderControlPlaneSpec struct {
127127

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

‎api/v1alpha1/codermcpserver_types.go‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ type MCPServersSpec struct {
3434
// enabled: false, and then changes nothing else. After the organization
3535
// is listed again, a spec edit enables the server. Deleting a
3636
// CoderMCPServer still deletes the config it created.
37-
//
38-
// This field has no effect yet: the CoderMCPServer controller is not
39-
// shipped.
4037
// +kubebuilder:validation:MinItems=1
4138
// +kubebuilder:validation:MaxItems=64
4239
// +kubebuilder:validation:items:MinLength=1
@@ -50,9 +47,6 @@ type MCPServersSpec struct {
5047
// without that user's choice. When false (the default), such objects
5148
// create and change nothing, and a server config that one of them
5249
// created is disabled in Coder, as for a removed organization.
53-
//
54-
// This field has no effect yet: the CoderMCPServer controller is not
55-
// shipped.
5650
// +optional
5751
AllowForceOn bool `json:"allowForceOn,omitempty"`
5852
}
@@ -76,7 +70,7 @@ type CoderMCPServerSpec struct {
7670
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="organization is immutable"
7771
Organization string `json:"organization"`
7872
// Slug is the Coder slug of the config, unique in its organization. The
79-
// model sees the server's tools as <slug>__<tool>.
73+
// model sees the server's tools as `<slug>__<tool>`.
8074
// +kubebuilder:validation:MinLength=1
8175
// +kubebuilder:validation:MaxLength=32
8276
// +kubebuilder:validation:Pattern=`^[a-z0-9]+(-[a-z0-9]+)*$`
@@ -175,7 +169,7 @@ type CoderMCPServerAuth struct {
175169
}
176170

177171
// CoderMCPServerAPIKeyAuth sends one Secret value in one header. Coder sends
178-
// the value as it is, so a bearer token is stored as "Bearer <token>".
172+
// the value as it is, so a bearer token is stored as `Bearer <token>`.
179173
type CoderMCPServerAPIKeyAuth struct {
180174
// Header is the HTTP header name.
181175
// +kubebuilder:validation:MinLength=1
@@ -277,7 +271,7 @@ type CoderMCPServerAccess struct {
277271
// from it.
278272
type CoderMCPServerCredentialStatus struct {
279273
// Field names the credential: apiKey, oauth2ClientSecret, or
280-
// header:<name>.
274+
// `header:<name>`.
281275
// +kubebuilder:validation:MinLength=1
282276
// +kubebuilder:validation:MaxLength=160
283277
Field string `json:"field"`

‎config/admission/status-operator-only.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# config/default does not apply this file. Apply it with
66
# `kubectl apply -f config/admission/`. If the operator runs as another
77
# ServiceAccount, change the user name in the expression first.
8+
# docs/how-to/manage-mcp-servers.md describes the policy.
89
apiVersion: admissionregistration.k8s.io/v1
910
kind: ValidatingAdmissionPolicy
1011
metadata:

‎config/crd/bases/coder.com_codercontrolplanes.yaml‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,8 +1400,7 @@ spec:
14001400
description: |-
14011401
MCPServers configures CoderMCPServer objects that use this control
14021402
plane. Removing it disables in Coder each server config that a
1403-
CoderMCPServer created through this control plane. It has no effect
1404-
yet: the CoderMCPServer controller is not shipped.
1403+
CoderMCPServer created through this control plane.
14051404
properties:
14061405
allowForceOn:
14071406
description: |-
@@ -1410,9 +1409,6 @@ spec:
14101409
without that user's choice. When false (the default), such objects
14111410
create and change nothing, and a server config that one of them
14121411
created is disabled in Coder, as for a removed organization.
1413-
1414-
This field has no effect yet: the CoderMCPServer controller is not
1415-
shipped.
14161412
type: boolean
14171413
organizations:
14181414
description: |-
@@ -1427,9 +1423,6 @@ spec:
14271423
enabled: false, and then changes nothing else. After the organization
14281424
is listed again, a spec edit enables the server. Deleting a
14291425
CoderMCPServer still deletes the config it created.
1430-
1431-
This field has no effect yet: the CoderMCPServer controller is not
1432-
shipped.
14331426
items:
14341427
maxLength: 32
14351428
minLength: 1

config/crd/dormant/coder.com_codermcpservers.yaml renamed to config/crd/bases/coder.com_codermcpservers.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ spec:
382382
slug:
383383
description: |-
384384
Slug is the Coder slug of the config, unique in its organization. The
385-
model sees the server's tools as <slug>__<tool>.
385+
model sees the server's tools as `<slug>__<tool>`.
386386
maxLength: 32
387387
minLength: 1
388388
pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
@@ -513,7 +513,7 @@ spec:
513513
field:
514514
description: |-
515515
Field names the credential: apiKey, oauth2ClientSecret, or
516-
header:<name>.
516+
`header:<name>`.
517517
maxLength: 160
518518
minLength: 1
519519
type: string

‎config/default/kustomization.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ resources:
66
- ../crd/bases/coder.com_coderaigatewaykeys.yaml
77
- ../crd/bases/coder.com_coderaiproviders.yaml
88
- ../crd/bases/coder.com_codercontrolplanes.yaml
9+
- ../crd/bases/coder.com_codermcpservers.yaml
910
- ../crd/bases/coder.com_coderprovisioners.yaml
1011
- ../crd/bases/coder.com_codertemplatetests.yaml
1112
- ../crd/bases/coder.com_coderworkspaceproxies.yaml

‎config/rbac/role.yaml‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ rules:
8686
resources:
8787
- coderaigatewaykeys
8888
- coderaiproviders
89+
- codermcpservers
8990
verbs:
9091
- get
9192
- list
@@ -97,6 +98,7 @@ rules:
9798
- coderaigatewaykeys/finalizers
9899
- coderaiproviders/finalizers
99100
- codercontrolplanes/finalizers
101+
- codermcpservers/finalizers
100102
- coderprovisioners/finalizers
101103
- codertemplatetests/finalizers
102104
- coderworkspaceproxies/finalizers
@@ -107,6 +109,7 @@ rules:
107109
resources:
108110
- coderaigatewaykeys/status
109111
- coderaiproviders/status
112+
- codermcpservers/status
110113
verbs:
111114
- get
112115
- update
@@ -135,12 +138,6 @@ rules:
135138
- get
136139
- patch
137140
- update
138-
- apiGroups:
139-
- coder.com
140-
resources:
141-
- codermcpservers
142-
verbs:
143-
- list
144141
- apiGroups:
145142
- coder.com
146143
resources:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Manages one MCP server config in a Coder organization. See
2+
# docs/how-to/manage-mcp-servers.md.
3+
#
4+
# Prerequisites:
5+
# - CoderControlPlane codercontrolplane-sample with status.operatorAccessReady
6+
# true and spec.mcpServers.organizations listing coder.
7+
# - Replace the placeholder key below. Never commit a real key.
8+
apiVersion: v1
9+
kind: Secret
10+
metadata:
11+
name: example-mcp-key
12+
namespace: coder
13+
labels:
14+
coder.com/credential: "true"
15+
annotations:
16+
# The origin of spec.url. The operator sends the key to no other origin.
17+
coder.com/credential-destinations: https://mcp.example.com
18+
type: Opaque
19+
stringData:
20+
api-key: replace-with-the-mcp-server-key
21+
---
22+
apiVersion: coder.com/v1alpha1
23+
kind: CoderMCPServer
24+
metadata:
25+
name: example
26+
namespace: coder
27+
spec:
28+
controlPlaneRef:
29+
name: codercontrolplane-sample
30+
# Immutable: Coder cannot move a config to another organization.
31+
organization: coder
32+
# Immutable: the model sees the tools as example__<tool>.
33+
slug: example
34+
displayName: Example
35+
url: https://mcp.example.com/mcp
36+
auth:
37+
type: api_key
38+
apiKey:
39+
header: X-Api-Key
40+
secretRef:
41+
name: example-mcp-key
42+
key: api-key
43+
# Exactly these groups can read the server. Every sync removes other
44+
# entries, also user grants added in the Coder dashboard.
45+
access:
46+
groups: [Everyone]

0 commit comments

Comments
 (0)