diff --git a/.cspell.json b/.cspell.json index df441da0..0d0ebbb3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -51,6 +51,8 @@ "stakater", "coderaigatewaykey", "coderaigatewaykeys", + "codermcpserver", + "codermcpservers", "coderaiprovider", "coderaiproviders", "codertemplatetest", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1607d5d3..14926786 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,6 +87,7 @@ jobs: - 'internal/controller/codertemplatetest*' - 'internal/controller/coderaiprovider*' - 'internal/controller/coderaigatewaykey*' + - 'internal/controller/codermcpserver*' - 'internal/controller/owned_secret.go' - 'internal/controller/reserved_secret_names.go' - 'internal/controller/credential_checks.go' @@ -172,22 +173,6 @@ jobs: exit 1 fi - - name: Verify the dormant CoderMCPServer CRD is not installed - # Remove this step when the CoderMCPServer controller ships (#155). - run: | - # grep exits 1 when nothing matches. Any other status (for example a missing file) fails the step. - status=0 - # The third pattern matches the CRD path entry in config/default/kustomization.yaml. - grep -n -E '^[[:space:]]+kind: CoderMCPServer$|codermcpservers\.coder\.com|coder\.com_codermcpservers\.yaml' \ - dist/install.yaml config/default/kustomization.yaml config/crd/bases/*.yaml || status=$? - if [[ "${status}" -eq 0 ]]; then - echo "assertion failed: the CoderMCPServer CRD must not ship before its controller" >&2 - exit 1 - elif [[ "${status}" -ne 1 ]]; then - echo "assertion failed: the CoderMCPServer CRD guard could not read the install manifests (grep exit ${status})" >&2 - exit 1 - fi - - name: Verify the dormant CoderWorkspacePool CRD is not installed # Remove this step when the CoderWorkspacePool controller ships (#153). run: | diff --git a/api/v1alpha1/codercontrolplane_types.go b/api/v1alpha1/codercontrolplane_types.go index 590be414..3514d490 100644 --- a/api/v1alpha1/codercontrolplane_types.go +++ b/api/v1alpha1/codercontrolplane_types.go @@ -127,8 +127,7 @@ type CoderControlPlaneSpec struct { // 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. + // CoderMCPServer created through this control plane. // +optional MCPServers *MCPServersSpec `json:"mcpServers,omitempty"` diff --git a/api/v1alpha1/codermcpserver_types.go b/api/v1alpha1/codermcpserver_types.go index 5a4d3464..1a05fe4e 100644 --- a/api/v1alpha1/codermcpserver_types.go +++ b/api/v1alpha1/codermcpserver_types.go @@ -34,9 +34,6 @@ type MCPServersSpec struct { // 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. // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=64 // +kubebuilder:validation:items:MinLength=1 @@ -50,9 +47,6 @@ type MCPServersSpec struct { // 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. // +optional AllowForceOn bool `json:"allowForceOn,omitempty"` } @@ -76,7 +70,7 @@ type CoderMCPServerSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="organization is immutable" Organization string `json:"organization"` // Slug is the Coder slug of the config, unique in its organization. The - // model sees the server's tools as __. + // model sees the server's tools as `__`. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=32 // +kubebuilder:validation:Pattern=`^[a-z0-9]+(-[a-z0-9]+)*$` @@ -175,7 +169,7 @@ type CoderMCPServerAuth struct { } // CoderMCPServerAPIKeyAuth sends one Secret value in one header. Coder sends -// the value as it is, so a bearer token is stored as "Bearer ". +// the value as it is, so a bearer token is stored as `Bearer `. type CoderMCPServerAPIKeyAuth struct { // Header is the HTTP header name. // +kubebuilder:validation:MinLength=1 @@ -277,7 +271,7 @@ type CoderMCPServerAccess struct { // from it. type CoderMCPServerCredentialStatus struct { // Field names the credential: apiKey, oauth2ClientSecret, or - // header:. + // `header:`. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=160 Field string `json:"field"` diff --git a/config/admission/status-operator-only.yaml b/config/admission/status-operator-only.yaml index f1cc5c6a..6f75298a 100644 --- a/config/admission/status-operator-only.yaml +++ b/config/admission/status-operator-only.yaml @@ -5,6 +5,7 @@ # config/default does not apply this file. Apply it with # `kubectl apply -f config/admission/`. If the operator runs as another # ServiceAccount, change the user name in the expression first. +# docs/how-to/manage-mcp-servers.md describes the policy. apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: diff --git a/config/crd/bases/coder.com_codercontrolplanes.yaml b/config/crd/bases/coder.com_codercontrolplanes.yaml index 3aa6f301..dd6e9a4a 100644 --- a/config/crd/bases/coder.com_codercontrolplanes.yaml +++ b/config/crd/bases/coder.com_codercontrolplanes.yaml @@ -1400,8 +1400,7 @@ spec: description: |- 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. + CoderMCPServer created through this control plane. properties: allowForceOn: description: |- @@ -1410,9 +1409,6 @@ spec: 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. type: boolean organizations: description: |- @@ -1427,9 +1423,6 @@ spec: 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. items: maxLength: 32 minLength: 1 diff --git a/config/crd/dormant/coder.com_codermcpservers.yaml b/config/crd/bases/coder.com_codermcpservers.yaml similarity index 99% rename from config/crd/dormant/coder.com_codermcpservers.yaml rename to config/crd/bases/coder.com_codermcpservers.yaml index d653518b..f2af937c 100644 --- a/config/crd/dormant/coder.com_codermcpservers.yaml +++ b/config/crd/bases/coder.com_codermcpservers.yaml @@ -382,7 +382,7 @@ spec: slug: description: |- Slug is the Coder slug of the config, unique in its organization. The - model sees the server's tools as __. + model sees the server's tools as `__`. maxLength: 32 minLength: 1 pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ @@ -513,7 +513,7 @@ spec: field: description: |- Field names the credential: apiKey, oauth2ClientSecret, or - header:. + `header:`. maxLength: 160 minLength: 1 type: string diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index d3f9b0e3..26972f97 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -6,6 +6,7 @@ resources: - ../crd/bases/coder.com_coderaigatewaykeys.yaml - ../crd/bases/coder.com_coderaiproviders.yaml - ../crd/bases/coder.com_codercontrolplanes.yaml + - ../crd/bases/coder.com_codermcpservers.yaml - ../crd/bases/coder.com_coderprovisioners.yaml - ../crd/bases/coder.com_codertemplatetests.yaml - ../crd/bases/coder.com_coderworkspaceproxies.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 561f059e..c6aa1f0e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -86,6 +86,7 @@ rules: resources: - coderaigatewaykeys - coderaiproviders + - codermcpservers verbs: - get - list @@ -97,6 +98,7 @@ rules: - coderaigatewaykeys/finalizers - coderaiproviders/finalizers - codercontrolplanes/finalizers + - codermcpservers/finalizers - coderprovisioners/finalizers - codertemplatetests/finalizers - coderworkspaceproxies/finalizers @@ -107,6 +109,7 @@ rules: resources: - coderaigatewaykeys/status - coderaiproviders/status + - codermcpservers/status verbs: - get - update @@ -135,12 +138,6 @@ rules: - get - patch - update -- apiGroups: - - coder.com - resources: - - codermcpservers - verbs: - - list - apiGroups: - coder.com resources: diff --git a/config/samples/coder_v1alpha1_codermcpserver.yaml b/config/samples/coder_v1alpha1_codermcpserver.yaml new file mode 100644 index 00000000..545bd181 --- /dev/null +++ b/config/samples/coder_v1alpha1_codermcpserver.yaml @@ -0,0 +1,46 @@ +# Manages one MCP server config in a Coder organization. See +# docs/how-to/manage-mcp-servers.md. +# +# Prerequisites: +# - CoderControlPlane codercontrolplane-sample with status.operatorAccessReady +# true and spec.mcpServers.organizations listing coder. +# - Replace the placeholder key below. Never commit a real key. +apiVersion: v1 +kind: Secret +metadata: + name: example-mcp-key + namespace: coder + labels: + coder.com/credential: "true" + annotations: + # The origin of spec.url. The operator sends the key to no other origin. + coder.com/credential-destinations: https://mcp.example.com +type: Opaque +stringData: + api-key: replace-with-the-mcp-server-key +--- +apiVersion: coder.com/v1alpha1 +kind: CoderMCPServer +metadata: + name: example + namespace: coder +spec: + controlPlaneRef: + name: codercontrolplane-sample + # Immutable: Coder cannot move a config to another organization. + organization: coder + # Immutable: the model sees the tools as example__. + slug: example + displayName: Example + url: https://mcp.example.com/mcp + auth: + type: api_key + apiKey: + header: X-Api-Key + secretRef: + name: example-mcp-key + key: api-key + # Exactly these groups can read the server. Every sync removes other + # entries, also user grants added in the Coder dashboard. + access: + groups: [Everyone] diff --git a/dist/install.yaml b/dist/install.yaml index 23b1e3b7..6cfcce5f 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -2062,8 +2062,7 @@ spec: description: |- 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. + CoderMCPServer created through this control plane. properties: allowForceOn: description: |- @@ -2072,9 +2071,6 @@ spec: 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. type: boolean organizations: description: |- @@ -2089,9 +2085,6 @@ spec: 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. items: maxLength: 32 minLength: 1 @@ -5199,6 +5192,563 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: codermcpservers.coder.com +spec: + group: coder.com + names: + kind: CoderMCPServer + listKind: CoderMCPServerList + plural: codermcpservers + singular: codermcpserver + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.organization + name: Organization + type: string + - jsonPath: .spec.slug + name: Slug + type: string + - jsonPath: .spec.auth.type + name: Auth + type: string + - jsonPath: .spec.enabled + name: Enabled + type: boolean + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: CoderMCPServer manages one MCP server config in a Coder organization. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + CoderMCPServerSpec defines one MCP server config in a Coder organization. + Coder trims every text field with strings.TrimSpace, so the patterns reject + leading and trailing Unicode white space. The class [\s\v\x{85}\p{Z}] is + exactly the set of runes that unicode.IsSpace reports. + properties: + access: + description: |- + Access, when set, makes the controller own the server's access list in + Coder: exactly the listed groups can read the server. Every sync + therefore also removes each user entry, for example a user that an admin + added in the Coder dashboard. Coder does not show the entry of a deleted + group, so the sync cannot remove it, but it grants nothing. + When omitted, the controller leaves the access list alone, and Coder's + default lets every organization member read the server. + properties: + groups: + description: |- + Groups are Coder group names in the server's organization. Everyone is + the group of all organization members. An empty list leaves the server + to organization admins. Coder reserves the names new and create. While a + listed group does not exist, the controller removes the access of every + group that this list does not name, adds none, disables the server if + the spec disables it, and waits with GroupNotFound for the rest. + items: + maxLength: 255 + minLength: 1 + pattern: ^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set + x-kubernetes-validations: + - message: access.groups must not hold new or create + rule: self.all(g, g != 'new' && g != 'create') + type: object + allowInPlanMode: + description: AllowInPlanMode offers the server in plan-mode chats. + type: boolean + auth: + description: Auth is how Coder authenticates to the server. + properties: + apiKey: + description: APIKey sends one Secret value in one header. + properties: + header: + description: Header is the HTTP header name. + maxLength: 128 + minLength: 1 + pattern: ^[!#$%&'*+.^_\x60|~0-9A-Za-z-]+$ + type: string + x-kubernetes-validations: + - message: header is reserved for the HTTP transport, MCP, + or Coder + rule: '!(self.lowerAscii() in [''host'', ''content-length'', + ''content-type'', ''content-encoding'', ''transfer-encoding'', + ''connection'', ''keep-alive'', ''upgrade'', ''te'', ''trailer'', + ''accept'', ''accept-encoding'', ''proxy-authorization'', + ''proxy-connection'', ''mcp-session-id'', ''mcp-protocol-version'', + ''last-event-id'']) && !self.lowerAscii().startsWith(''x-coder-'')' + secretRef: + description: |- + SecretRef references the key in a Secret in the same namespace that + holds the header value. The Secret must opt in to the credential Secret + contract and list the origin of spec.url. + properties: + key: + description: Key is the key inside the Secret data map. + type: string + name: + description: Name is the Kubernetes Secret name. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: secretRef.name must not be empty + rule: size(self.name) > 0 + - message: secretRef.key must not be empty + rule: has(self.key) && size(self.key) > 0 + required: + - header + - secretRef + type: object + customHeaders: + description: CustomHeaders sends each Secret value in its own + header. + items: + description: CoderMCPServerHeader is one header whose value + comes from a Secret. + properties: + name: + description: Name is the HTTP header name. + maxLength: 128 + minLength: 1 + pattern: ^[!#$%&'*+.^_\x60|~0-9A-Za-z-]+$ + type: string + x-kubernetes-validations: + - message: header is reserved for the HTTP transport, MCP, + or Coder + rule: '!(self.lowerAscii() in [''host'', ''content-length'', + ''content-type'', ''content-encoding'', ''transfer-encoding'', + ''connection'', ''keep-alive'', ''upgrade'', ''te'', + ''trailer'', ''accept'', ''accept-encoding'', ''proxy-authorization'', + ''proxy-connection'', ''mcp-session-id'', ''mcp-protocol-version'', + ''last-event-id'']) && !self.lowerAscii().startsWith(''x-coder-'')' + secretRef: + description: |- + SecretRef references the key in a Secret in the same namespace that + holds the header value. The Secret must opt in to the credential Secret + contract and list the origin of spec.url. + properties: + key: + description: Key is the key inside the Secret data map. + type: string + name: + description: Name is the Kubernetes Secret name. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: secretRef.name must not be empty + rule: size(self.name) > 0 + - message: secretRef.key must not be empty + rule: has(self.key) && size(self.key) > 0 + required: + - name + - secretRef + type: object + maxItems: 16 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: auth.customHeaders names must be unique, ignoring case + rule: self.all(a, self.filter(b, b.name.lowerAscii() == a.name.lowerAscii()).size() + == 1) + oauth2: + description: |- + OAuth2 lets each user connect with an OAuth2 client that the admin + registered at the authorization server. + properties: + authURL: + description: AuthURL is the authorization endpoint that users' + browsers open. + maxLength: 2048 + minLength: 1 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + x-kubernetes-validations: + - message: authURL must be a valid URL with a host + rule: isURL(self) && url(self).getHostname() != '' + - message: authURL must be an http:// or https:// URL with + a host and no user info + rule: self.matches('^https?://[^/?#@\\s]+([/?#].*)?$') + clientID: + description: ClientID is the OAuth2 client ID. + maxLength: 256 + minLength: 1 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + clientSecretRef: + description: |- + ClientSecretRef references the key in a Secret in the same namespace + that holds the client secret. The Secret must opt in to the credential + Secret contract and list the origins of spec.url, authURL, tokenURL and + revocationURL. + properties: + key: + description: Key is the key inside the Secret data map. + type: string + name: + description: Name is the Kubernetes Secret name. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: clientSecretRef.name must not be empty + rule: size(self.name) > 0 + - message: clientSecretRef.key must not be empty + rule: has(self.key) && size(self.key) > 0 + revocationURL: + description: |- + RevocationURL is the https:// token revocation endpoint. Coder sends the + client secret there when a user disconnects. + maxLength: 2048 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + x-kubernetes-validations: + - message: revocationURL must be a valid URL with a host + rule: isURL(self) && url(self).getHostname() != '' + - message: revocationURL must be an https:// URL with a host + and no user info + rule: self.matches('^https://[^/?#@\\s]+([/?#].*)?$') + scopes: + description: Scopes are the OAuth2 scopes that Coder requests. + items: + maxLength: 256 + minLength: 1 + pattern: ^[!#-\[\]-~]+$ + type: string + maxItems: 32 + type: array + x-kubernetes-list-type: set + tokenURL: + description: TokenURL is the token endpoint that Coder sends + the client secret to. + maxLength: 2048 + minLength: 1 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + x-kubernetes-validations: + - message: tokenURL must be a valid URL with a host + rule: isURL(self) && url(self).getHostname() != '' + - message: tokenURL must be an http:// or https:// URL with + a host and no user info + rule: self.matches('^https?://[^/?#@\\s]+([/?#].*)?$') + required: + - authURL + - clientID + - clientSecretRef + - tokenURL + type: object + type: + description: |- + Type is the auth type. Coder's user_oidc type is not supported, because + it sends each user's login token to the server URL. + enum: + - none + - api_key + - custom_headers + - oauth2 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: auth.apiKey must be set exactly when auth.type is api_key + rule: (self.type == 'api_key') == has(self.apiKey) + - message: auth.customHeaders must be set exactly when auth.type is + custom_headers + rule: (self.type == 'custom_headers') == has(self.customHeaders) + - message: auth.oauth2 must be set exactly when auth.type is oauth2 + rule: (self.type == 'oauth2') == has(self.oauth2) + availability: + default: default_off + description: |- + Availability decides how chats offer the server. force_on adds it to + every new chat of a user who can read it. Coder's web UI preselects + default_on servers. The Coder server treats default_on and default_off + alike. + enum: + - force_on + - default_on + - default_off + type: string + controlPlaneRef: + description: |- + ControlPlaneRef names the CoderControlPlane in the same namespace. The + controller calls Coder with that control plane's operator token. + properties: + name: + description: Name is the CoderControlPlane name. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: controlPlaneRef is immutable + rule: self == oldSelf + description: + description: Description is a short summary of what the server provides. + maxLength: 512 + pattern: ^[^\s\v\x{85}\p{Z}]([\s\S]*[^\s\v\x{85}\p{Z}])?$ + type: string + displayName: + description: DisplayName is the name that Coder shows to users in + chat. + maxLength: 128 + minLength: 1 + pattern: ^[^\s\v\x{85}\p{Z}]([\s\S]*[^\s\v\x{85}\p{Z}])?$ + type: string + enabled: + default: true + description: |- + Enabled turns the server on or off in Coder. Chats never connect to a + disabled server. When omitted, the default is true. + type: boolean + forwardCoderHeaders: + description: |- + ForwardCoderHeaders sends the chat owner, chat and workspace IDs to the + server as X-Coder-* headers. Coder sends no token in them. + type: boolean + iconURL: + description: IconURL is the icon URL, path or emoji that Coder shows. + maxLength: 2048 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + modelIntent: + description: ModelIntent asks the model to state its intent with each + tool call. + type: boolean + organization: + description: |- + Organization is the name of the Coder organization that holds the + config. Coder cannot move a config to another organization. Coder + reserves the names new and create. + maxLength: 32 + minLength: 1 + pattern: ^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$ + type: string + x-kubernetes-validations: + - message: organization must not be new or create + rule: self != 'new' && self != 'create' + - message: organization is immutable + rule: self == oldSelf + slug: + description: |- + Slug is the Coder slug of the config, unique in its organization. The + model sees the server's tools as `__`. + maxLength: 32 + minLength: 1 + pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ + type: string + x-kubernetes-validations: + - message: slug is immutable + rule: self == oldSelf + transport: + default: streamable_http + description: Transport is the MCP transport that Coder uses. + enum: + - streamable_http + - sse + type: string + url: + description: |- + URL is the http:// or https:// MCP endpoint that Coder connects to. It + must not hold user info. Every Secret in auth must list its origin as an + allowed destination. + maxLength: 2048 + minLength: 1 + pattern: ^[^\s\v\x{85}\p{Z}]+$ + type: string + x-kubernetes-validations: + - message: url must be a valid URL with a host + rule: isURL(self) && url(self).getHostname() != '' + - message: url must be an http:// or https:// URL with a host and + no user info + rule: self.matches('^https?://[^/?#@\\s]+([/?#].*)?$') + required: + - auth + - controlPlaneRef + - displayName + - organization + - slug + - url + type: object + status: + description: CoderMCPServerStatus defines the observed state of a CoderMCPServer. + properties: + appliedGeneration: + 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: + description: Conditions are Ready, Reconciling, and Stalled. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configID: + description: ConfigID is the Coder MCP server config ID. + type: string + createAttemptTime: + description: |- + CreateAttemptTime is set before every create request. While it is set + and ConfigID is empty, a create request can be in flight or already + committed in Coder. + format: date-time + type: string + credentials: + description: Credentials record the Secret values last sent to Coder. + items: + description: |- + CoderMCPServerCredentialStatus records which Secret value was last sent to + Coder for one credential field. It holds no secret data and nothing derived + from it. + properties: + field: + description: |- + Field names the credential: apiKey, oauth2ClientSecret, or + `header:`. + maxLength: 160 + minLength: 1 + type: string + secretKey: + description: SecretKey is the key in the Secret that was read. + type: string + secretName: + description: SecretName is the Secret name that was read. + type: string + secretResourceVersion: + description: SecretResourceVersion is the resourceVersion of + the Secret that was read. + type: string + secretUID: + description: SecretUID is the UID of the Secret that was read. + type: string + required: + - field + type: object + type: array + x-kubernetes-list-map-keys: + - field + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration is the generation the controller last + processed. + format: int64 + type: integer + organizationID: + description: |- + OrganizationID is the Coder organization ID that spec.organization + resolved to first. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.20.0 @@ -6496,6 +7046,7 @@ rules: resources: - coderaigatewaykeys - coderaiproviders + - codermcpservers verbs: - get - list @@ -6507,6 +7058,7 @@ rules: - coderaigatewaykeys/finalizers - coderaiproviders/finalizers - codercontrolplanes/finalizers + - codermcpservers/finalizers - coderprovisioners/finalizers - codertemplatetests/finalizers - coderworkspaceproxies/finalizers @@ -6517,6 +7069,7 @@ rules: resources: - coderaigatewaykeys/status - coderaiproviders/status + - codermcpservers/status verbs: - get - update @@ -6545,12 +7098,6 @@ rules: - get - patch - update -- apiGroups: - - coder.com - resources: - - codermcpservers - verbs: - - list - apiGroups: - coder.com resources: diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index c6a02ff4..52533e09 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -31,7 +31,7 @@ graph TD | --- | --- | --- | --- | | Code | `internal/app/controllerapp/`, `internal/controller/` | `internal/app/apiserverapp/`, `internal/aggregated/storage/`, `internal/aggregated/coder/` | `internal/app/mcpapp/` | | Listens on | `:8081` (`/healthz`, `/readyz`) | `:6443` HTTPS (default) | `127.0.0.1:8090` only. `/mcp` requires the bearer token. `/healthz` and `/readyz` do not. | -| Resources | `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey` | `coderworkspaces`, `codertemplates`, `codertemplateversions` | Tools for control planes, templates, workspaces, events, pod logs, and run state | +| Resources | `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey`, `CoderMCPServer` | `coderworkspaces`, `codertemplates`, `codertemplateversions` | Tools for control planes, templates, workspaces, events, pod logs, and run state | ### Controller @@ -75,8 +75,9 @@ graph TD | Path | Contents | | --- | --- | -| `config/crd/bases/` | The generated CRDs for `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, and `CoderAIGatewayKey` | +| `config/crd/bases/` | The generated CRDs for `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey`, and `CoderMCPServer` | | `config/rbac/` | The `coder-k8s` ServiceAccount, the `manager-role` ClusterRole, and the bindings (including auth-delegator) | +| `config/admission/` | Opt-in admission policies that the install bundle does not apply, for example the operator-only status write policy for `CoderMCPServer` | | `config/apiserver-standalone/` | For standalone `--app=aggregated-apiserver`: the `coder-k8s-apiserver` ServiceAccount, its RBAC, and the placeholder Secret for the serving CA | | `deploy/deployment.yaml` | The `coder-k8s` Deployment (default `--app=all`) | | `deploy/apiserver-service.yaml`, `deploy/apiserver-apiservice.yaml` | The Service and APIService that make the aggregated API available | diff --git a/docs/how-to/deploy-controller.md b/docs/how-to/deploy-controller.md index ee0691ed..88702c76 100644 --- a/docs/how-to/deploy-controller.md +++ b/docs/how-to/deploy-controller.md @@ -1,6 +1,6 @@ # Deploy the controller -Run `coder-k8s` as an operator only (`--app=controller`). The operator reconciles `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, and `CoderAIGatewayKey` resources. +Run `coder-k8s` as an operator only (`--app=controller`). The operator reconciles `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey`, and `CoderMCPServer` resources. Run the commands from a clone of this repository. diff --git a/docs/how-to/manage-mcp-servers.md b/docs/how-to/manage-mcp-servers.md new file mode 100644 index 00000000..b216fc8a --- /dev/null +++ b/docs/how-to/manage-mcp-servers.md @@ -0,0 +1,286 @@ +# Manage MCP servers with CoderMCPServer + +A `CoderMCPServer` manages one MCP server config in a Coder organization (Coder's **AI settings > MCP servers**). Coder chats then offer the server's tools to the model. The operator creates the config, keeps it equal to the spec, and deletes it when you delete the object. + +!!! note "Not the coder-k8s MCP server" + This page is about MCP servers that Coder chats use. To run the MCP server that coder-k8s itself ships (`--app=mcp-http`), see [Run the MCP server](mcp-server.md). + +!!! warning "Who may write these resources" + The operator calls Coder as a site owner. A subject who can create or update `CoderMCPServer` objects acts as an organization admin for MCP servers in every organization that the control plane allows. See [Grant access](#grant-access). + +## Before you start + +- A `CoderControlPlane` in the same namespace with `status.operatorAccessReady: true`. The operator user must be a site owner. Without that, a create cannot be confirmed, and the object waits with `ConfirmingCreate`. +- Coder v2.37.2 or later. The Kind end-to-end test runs Coder v2.37.2, and the operator is built against the v2.37.3 SDK. +- The control plane opt-in below. + +## 1. Allow organizations on the control plane + +The control plane admin decides where `CoderMCPServer` objects can reach. Without `spec.mcpServers`, every `CoderMCPServer` of the control plane stalls with `MCPServersNotAllowed`, and the operator sends no MCP request. + +```yaml +apiVersion: coder.com/v1alpha1 +kind: CoderControlPlane +metadata: + name: coder + namespace: coder +spec: + mcpServers: + organizations: [coder] + allowForceOn: false +``` + +- `organizations` lists 1 to 64 Coder organization names. The operator resolves each name to its Coder organization ID and compares IDs, so the letter case of a name does not matter. A name that does not resolve yet allows nothing. The operator checks again every 10 minutes. +- `allowForceOn` lets objects use `availability: force_on`. A `force_on` server joins every new chat of each user who can read it, without that user's choice. The default is `false`. Then a `force_on` object stalls with `ForceOnNotAllowed`. +- When you remove an organization, remove `spec.mcpServers`, or set `allowForceOn` back to `false`, the operator disables each affected server in Coder. It sends one update that holds only `enabled: false`, and it reads no credential. Then it changes nothing more. The object stalls with `MCPServersNotAllowed` or `ForceOnNotAllowed`. After you restore the opt-in, the object reports `DisabledExternally` until you edit its spec. Edit the spec (any change, for example `description`) to enable the server again. +- Deleting a `CoderMCPServer` still deletes its config when the opt-in is gone. + +## 2. Prepare the credential Secret + +An `auth.type` of `api_key`, `custom_headers` or `oauth2` takes its credential from a Secret. The operator reads a value only from a Secret that opted in to the credential contract: + +- Type `Opaque`. +- The label `coder.com/credential: "true"`. +- The annotation `coder.com/credential-destinations` with the origin of every URL that receives the value. An origin is `scheme://host`, plus `:port` for a port that is not the default. Separate more than one origin with commas. + +| `auth.type` | Value | Origins that the Secret must list | +| --- | --- | --- | +| `none` | no Secret | none | +| `api_key` | `apiKey.secretRef`, sent in the header `apiKey.header` | `spec.url` | +| `custom_headers` | one `secretRef` per header, up to 16 headers | `spec.url` | +| `oauth2` | `oauth2.clientSecretRef` | `spec.url`, `authURL`, `tokenURL`, and `revocationURL` when set | + +```bash +kubectl -n coder create secret generic example-mcp-key --from-literal=api-key="$MCP_KEY" +kubectl -n coder label secret example-mcp-key coder.com/credential=true +kubectl -n coder annotate secret example-mcp-key coder.com/credential-destinations=https://mcp.example.com +``` + +The operator refuses a value that is empty, larger than 8 KiB, starts or ends with whitespace (a trailing newline counts), or is not valid UTF-8. A header value must also hold no control byte such as CR, LF or NUL. Coder sends an API key exactly as stored, so store a bearer token as `Bearer `. [Configure credential Secrets](workspace-proxy-credentials.md#checks-the-operator-runs) lists every rule of the contract. + +List only origins that you trust with the credential on every path. Coder sends the API key and the header values again on every request, also after a redirect to another origin. So a listed origin means "this origin and every place that it sends Coder". A plain `http://` URL is allowed for servers in the cluster, but Coder then sends the credential in clear text. The Secret must list the `http://` origin. + +## 3. Create the server + +```yaml +apiVersion: coder.com/v1alpha1 +kind: CoderMCPServer +metadata: + name: example + namespace: coder +spec: + controlPlaneRef: + name: coder + organization: coder + slug: example + displayName: Example + url: https://mcp.example.com/mcp + auth: + type: api_key + apiKey: + header: X-Api-Key + secretRef: + name: example-mcp-key + key: api-key + access: + groups: [Everyone] +``` + +```bash +kubectl apply -f example-mcp-server.yaml +kubectl -n coder wait --for=condition=Ready codermcpserver/example --timeout=2m +``` + +The sample, with its Secret, is in `config/samples/coder_v1alpha1_codermcpserver.yaml`. + +- `controlPlaneRef`, `organization` and `slug` cannot change. The model sees the tools as `__`. To rename a slug, delete the object and create a new one. Chats then lose the old server. +- The operator never takes over a config that it did not create. When the organization already has a config with the slug, the object stalls with `SlugConflict`, and the operator sends no write. +- The operator creates the config disabled, with a temporary display name. It then applies the spec. Members do not see the config before that. +- The operator pins the organization ID at the first lookup. If the name later resolves to another ID, the object stalls with `OrganizationChanged`. Re-create the object to use the new organization. +- Coder's `user_oidc` auth type is not supported. If someone sets it in the dashboard, the operator sets the auth type back at the next sync. + +## 4. Read the result + +```bash +kubectl -n coder get codermcpservers +kubectl -n coder get codermcpserver example -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}' +``` + +The object has the conditions `Ready`, `Reconciling` and `Stalled`. A stalled object waits for a change of its spec or of an input. + +| Reason | Meaning | +| --- | --- | +| `Applied` | Coder matches the spec (`Ready=True`). | +| `ControlPlaneNotReady`, `OperatorAccessNotReady` | The control plane cannot serve Coder calls yet. | +| `CoderUnavailable` | A Coder call failed. The operator retries every 30 seconds. | +| `OrganizationNotFound`, `OrganizationChanged` | The organization does not exist, or its ID changed. | +| `MCPServersNotAllowed`, `ForceOnNotAllowed` | The control plane does not allow this object. See [step 1](#1-allow-organizations-on-the-control-plane). | +| `CredentialSecretNotFound`, `CredentialNotOptedIn`, `CredentialDestinationNotAllowed`, `CredentialRefused`, `CredentialKeyMissing`, `CredentialMalformed` | A credential Secret fails the contract. See [Credential kill switch](#credential-kill-switch). | +| `SlugConflict` | The organization has a config with this slug that the operator did not create. | +| `CreatingConfig`, `ConfirmingCreate`, `CreateRetrying` | A create is in progress, or the operator reads Coder to learn whether a create committed. | +| `GroupNotFound` | A name in `access.groups` is not a Coder group yet. See [Access](#access). | +| `CoderRejected` | Coder rejected the request. Fix the spec. | +| `CoderAnswerMismatch` | Coder's answer does not match the request, for example an answer for another config. The operator checks Coder again later. | +| `DisabledExternally`, `DeletedExternally`, `CredentialClearedExternally` | Someone disabled the server, deleted it, or cleared a credential. `DisabledExternally` also follows a disable by the operator itself, after the opt-in or the credential consent came back. See [Kill switches](#kill-switches). | + +## Access + +`spec.access` decides which Coder groups can read the server. Coder checks it when a chat selects the server. + +- With `access` omitted, the operator never reads or writes the server's access list. Coder's default lets the organization's `Everyone` group read the server. So every member of the organization can use it. Removing `access` later leaves the last access list in place. +- With `access` set, the access list is exactly `access.groups`, each with read access. `spec.access` lists groups only. So every sync removes user grants that someone added in the Coder dashboard, and every group that the list does not name. +- `access: {}` or `groups: []` leaves the server to organization admins. +- `Everyone` is the group of all organization members. Without a Coder license, only `Everyone` exists. + +When a group in `access.groups` does not exist, the sync fails closed: + +1. It removes every entry that `access.groups` no longer names, also user entries. +2. It adds nothing. Access never widens while a group is missing. +3. If the same spec generation disables the server (`enabled: false`), it sends one update that holds only `enabled: false`. +4. It leaves the rest of the generation unapplied. The object stalls with `GroupNotFound` and checks again every 10 minutes. +5. Once the missing groups exist, the sync adds them and applies the rest of the generation. + +A new server stays disabled in Coder while one of its groups is missing. + +Removing a group from `access` does not detach chats that already use the server. Those chats keep it until the server is disabled or deleted. In an emergency, disable the server (see [Kill switches](#kill-switches)). + +## Rotate a credential + +Change the Secret value. The operator sends the new value with one update. Coder stores all custom headers in one field, so a change of one header value sends every header value. A change of only the Secret's labels or annotations sends no value, as long as the Secret keeps its consent for the origins. The operator remembers a hash of each sent value in memory only. After an operator restart, the first change of the Secret sends its value once more, also a change of only its metadata. Coder records each credential write in its audit log, with the value marked as secret. + +A new value that breaks the contract, for example a value with a trailing newline, triggers the [credential kill switch](#credential-kill-switch). + +Coder shows only whether a credential is set, not its value. So the operator cannot detect a dashboard replacement of a value, or the removal of one custom header of several. The next change of the Secret value overwrites it. + +## Change the URL + +The Secret must list the origin of every URL that receives its value. Change a URL in this order: + +1. Add the new origin to `coder.com/credential-destinations`. +2. Change `spec.url` (or an OAuth2 URL). +3. Wait for `Ready=True`. Then remove the old origin, if nothing else uses it. + +If you change the URL before the Secret lists the new origin, the object stalls with `CredentialDestinationNotAllowed`. The operator sends nothing, and Coder keeps the old URL and credential, which the Secret still allows. When you add the origin, the operator applies the new URL. + +An update that changes a URL or the auth type also sends every credential of the auth type. Coder deletes every user's OAuth2 token for the server when `url`, the auth type, `tokenURL`, `revocationURL` or `clientID` changes. Users must connect again. + +## Kill switches + +### Dashboard changes + +After the operator applied a spec generation, three dashboard changes are kill switches: + +- A disable stalls the object with `DisabledExternally`. +- A delete stalls it with `DeletedExternally`. +- A cleared credential, or a changed auth type, stalls it with `CredentialClearedExternally`. + +The operator then changes nothing until the spec changes. A spec edit (any change of `spec`, for example `displayName`) enables the server again, sends the Secret's credential again, or creates the server again. + +Coder sends no events. The operator sees a dashboard change at its next read: at the resync, about every 10 minutes, or at once when the object, its Secret or its control plane changes. Until then the object can still show `Ready=True`. + +### Credential kill switch + +For a server that the operator created, every credential error disables the server in Coder: the Secret is deleted, loses its label, no longer lists an origin that Coder sends the credential to, is refused, has no such key, or holds a malformed value. The update holds only `enabled: false`, with no credential and no URL. The operator first checks what Coder holds. When every credential that Coder holds comes from a recorded Secret that still allows the URLs that Coder holds, the operator disables nothing and sends nothing. This is the case during a URL change to an origin that the Secret does not list yet. + +The object stalls with the credential reason. Repairing the Secret does not enable the server again: the object then reports `DisabledExternally`. To enable it, fix the Secret, then edit the spec. When the disable fails, the operator retries every 30 seconds, and the condition message starts with `Coder can still send the credentials`. + +### The disable window + +The operator marks the disable in the object's status before it sends the disable to Coder. This holds the disable also when the update reaches Coder but its answer is lost. If the operator stops after the status write and before the update, the next reconcile still sends the disable while consent or the opt-in stays withdrawn. + +This window has one known limit. If consent or the opt-in comes back between the status write and the update, the sync applies the current spec without a later spec edit, and the server stays enabled. Coder never disabled the server in that window. The sync checks every credential first, so no credential goes to an origin without consent. + +### Emergency + +To stop a server at once: + +1. Disable the server in the Coder dashboard (**AI settings > MCP servers**). The object reports `DisabledExternally`, and the operator does not enable it again until a spec edit. +2. Or set `spec.enabled: false`. +3. If a credential leaked, revoke it at the MCP server too. + +Disabling stops every chat from using the server, also chats that selected it earlier. + +## Delete a server + +Deleting the object deletes the config in Coder. Coder removes the server from every chat and deletes the users' OAuth2 tokens for it. The finalizer `coder.com/mcp-server-cleanup` keeps the object until then. + +- The operator deletes only a config that this object created. With only an unconfirmed create, it reads Coder first. It deletes a config that carries this object's temporary display name, and it never touches another config with the slug. A create can still commit for a short time, so within 2 minutes of the create request the operator reads again before it trusts that the config is absent. +- When Coder is unreachable, or the control plane has no operator access, the finalizer stays and the operator retries every 60 seconds. The operator trusts only Coder's own 404 as proof that the config is gone. A 404 from a proxy keeps the finalizer. +- When the control plane is gone, nothing can delete the config. The finalizer goes at once. The operator logs the slug and the config ID with the reason `ControlPlaneGone`. Delete the config in the Coder dashboard. +- While the control plane is being deleted, the operator sends one delete with a 10-second limit, then removes the finalizer, also when that delete fails. So a namespace deletion finishes, but it can leave the config in Coder. +- A withdrawn opt-in never blocks a delete. + +Delete `CoderMCPServer` objects before their control plane and before their namespace. + +To release a stuck object by hand, remove the finalizer with a JSON patch whose `test` operation checks the index first, as for [AI providers](manage-ai-providers.md#stuck-finalizer). Then check **AI settings > MCP servers** in the dashboard for a leftover config with the slug. + +## Coder-side limits + +These limits come from Coder, and the operator cannot change them: + +- Coder checks server access when a chat selects the server. Chats that already use it keep it until the server is disabled or deleted. +- Coder applies tool allow and deny lists by exact, case-sensitive tool name. With both lists set, Coder ignores the deny list. This kind does not manage tool lists yet. Lists that you set in the dashboard stay. +- A change of the URL deletes every user's OAuth2 token for the server. +- Coder encrypts the stored credentials at rest only when enterprise database encryption keys are set. +- Coder has no address guard for MCP traffic. A server URL can point at a cluster-internal or private address. `auth.type: none` needs no Secret, so only the control plane opt-in and RBAC limit it. +- Coder's MCP client sets the API key, the header values and users' bearer tokens on every request, also after a redirect to another origin. A Secret's destination list therefore means "this origin and every place that it sends Coder". + +## Grant access + +The operator acts as a Coder site owner. So each permission in the control plane's namespace maps to Coder power: + +| Permission | Coder power | +| --- | --- | +| `create`, `update`, `patch` on `codermcpservers` | Organization admin for MCP servers in the organizations that the control plane lists: add servers with any URL and choose the access groups. With `allowForceOn`, also add servers to every member's new chats. The operator sends a credential only to origins that its Secret lists. | +| `update`, `patch` on `codermcpservers/status` | Operator only. `status.configID` tells the operator which config an object owns. A holder can point an object at any config in the listed organizations, then change or delete that config. | +| `delete` on `codermcpservers` | Removes owned servers from every chat | +| `update`, `patch` on `codercontrolplanes` | Chooses the organizations and allows `force_on` | +| `patch` on credential Secrets | Decides whether a credential can be used (label) and where it can go (annotation) | +| `get`, `list` on `codermcpservers` | Sees slugs, URLs, Secret names, Coder IDs and group names. No secrets. | + +A viewer Role: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: codermcpserver-viewer + namespace: coder +rules: + - apiGroups: ["coder.com"] + resources: ["codermcpservers"] + verbs: ["get", "list", "watch"] +``` + +An editor Role. Give it only to subjects who may act as an organization admin for MCP servers and who can also read the credential Secrets that their objects use: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: codermcpserver-editor + namespace: coder +rules: + - apiGroups: ["coder.com"] + resources: ["codermcpservers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` + +Neither Role grants `codermcpservers/status`. Only the operator ServiceAccount must write it. + +!!! warning "Wildcards grant subresources" + A rule with `resources: ["*"]` in the `coder.com` group also grants `codermcpservers/status`. Do not give such a rule to anyone but the operator. + +A `ResourceQuota` on `count/codermcpservers.coder.com` limits how many servers a namespace can create. + +The emergency path needs no Kubernetes access: a Coder admin disables the server in the dashboard. + +### Enforce operator-only status writes + +`config/admission/status-operator-only.yaml` holds an opt-in `ValidatingAdmissionPolicy` and its binding. The policy denies every update and patch of `codermcpservers/status` by any subject except the operator ServiceAccount `system:serviceaccount:coder-system:coder-k8s`. It denies cluster admins too. The install bundle does not apply it. Apply it yourself: + +```bash +kubectl apply -f config/admission/ +``` + +`ValidatingAdmissionPolicy` is GA in Kubernetes v1.30, so the policy needs v1.30 or later. If the operator runs as another ServiceAccount or in another namespace, change the user name in the policy's expression first, or the operator cannot write status. To fix a status by hand, delete the binding `coder-k8s-status-operator-only` first, then apply it again. diff --git a/docs/index.md b/docs/index.md index 9016006f..be93c69a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ Use native Kubernetes APIs to run and manage [Coder](https://coder.com). | Component | Resources | | --- | --- | -| Operator | `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey` (`coder.com/v1alpha1`) | +| Operator | `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy`, `CoderTemplateTest`, `CoderAIProvider`, `CoderAIGatewayKey`, `CoderMCPServer` (`coder.com/v1alpha1`) | | Aggregated API server | `CoderWorkspace`, `CoderTemplate`, `CoderTemplateVersion` (`aggregation.coder.com/v1alpha1`) | | MCP server | Operational tools over HTTP | diff --git a/docs/reference/api/codercontrolplane.md b/docs/reference/api/codercontrolplane.md index 38356227..581dc1ac 100644 --- a/docs/reference/api/codercontrolplane.md +++ b/docs/reference/api/codercontrolplane.md @@ -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. 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. | +| `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. | | `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. | @@ -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 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. | +| `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. | +| `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. | ### OperatorAccessSpec diff --git a/docs/reference/api/codermcpserver.md b/docs/reference/api/codermcpserver.md new file mode 100644 index 00000000..831f4856 --- /dev/null +++ b/docs/reference/api/codermcpserver.md @@ -0,0 +1,136 @@ + + +# `CoderMCPServer` + +## API identity + +- Group/version: `coder.com/v1alpha1` +- Kind: `CoderMCPServer` +- Resource: `codermcpservers` +- Scope: namespaced + +## Spec + +| Field | Type | Description | +| --- | --- | --- | +| `controlPlaneRef` | [CoderControlPlaneReference](#codercontrolplanereference) | ControlPlaneRef names the CoderControlPlane in the same namespace. The controller calls Coder with that control plane's operator token. | +| `organization` | string | Organization is the name of the Coder organization that holds the config. Coder cannot move a config to another organization. Coder reserves the names new and create. | +| `slug` | string | Slug is the Coder slug of the config, unique in its organization. The model sees the server's tools as `__`. | +| `displayName` | string | DisplayName is the name that Coder shows to users in chat. | +| `description` | string | Description is a short summary of what the server provides. | +| `iconURL` | string | IconURL is the icon URL, path or emoji that Coder shows. | +| `url` | string | URL is the http:// or https:// MCP endpoint that Coder connects to. It must not hold user info. Every Secret in auth must list its origin as an allowed destination. | +| `transport` | string | Transport is the MCP transport that Coder uses. | +| `availability` | string | Availability decides how chats offer the server. force_on adds it to every new chat of a user who can read it. Coder's web UI preselects default_on servers. The Coder server treats default_on and default_off alike. | +| `enabled` | boolean | Enabled turns the server on or off in Coder. Chats never connect to a disabled server. When omitted, the default is true. | +| `allowInPlanMode` | boolean | AllowInPlanMode offers the server in plan-mode chats. | +| `modelIntent` | boolean | ModelIntent asks the model to state its intent with each tool call. | +| `forwardCoderHeaders` | boolean | ForwardCoderHeaders sends the chat owner, chat and workspace IDs to the server as X-Coder-* headers. Coder sends no token in them. | +| `auth` | [CoderMCPServerAuth](#codermcpserverauth) | Auth is how Coder authenticates to the server. | +| `access` | [CoderMCPServerAccess](#codermcpserveraccess) | Access, when set, makes the controller own the server's access list in Coder: exactly the listed groups can read the server. Every sync therefore also removes each user entry, for example a user that an admin added in the Coder dashboard. Coder does not show the entry of a deleted group, so the sync cannot remove it, but it grants nothing. When omitted, the controller leaves the access list alone, and Coder's default lets every organization member read the server. | + +## Status + +| Field | Type | Description | +| --- | --- | --- | +| `observedGeneration` | integer | ObservedGeneration is the generation the controller last processed. | +| `appliedGeneration` | integer | 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. | +| `organizationID` | string | OrganizationID is the Coder organization ID that spec.organization resolved to first. | +| `configID` | string | ConfigID is the Coder MCP server config ID. | +| `createAttemptTime` | [Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta) | CreateAttemptTime is set before every create request. While it is set and ConfigID is empty, a create request can be in flight or already committed in Coder. | +| `credentials` | [CoderMCPServerCredentialStatus](#codermcpservercredentialstatus) array | Credentials record the Secret values last sent to Coder. | +| `conditions` | [Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array | Conditions are Ready, Reconciling, and Stalled. | + +## Referenced types + +### CoderControlPlaneReference + +CoderControlPlaneReference names a CoderControlPlane in the same namespace. + +| Field | Type | Description | +| --- | --- | --- | +| `name` | string | Name is the CoderControlPlane name. | + +### CoderMCPServerAPIKeyAuth + +CoderMCPServerAPIKeyAuth sends one Secret value in one header. Coder sends +the value as it is, so a bearer token is stored as `Bearer `. + +| Field | Type | Description | +| --- | --- | --- | +| `header` | string | Header is the HTTP header name. | +| `secretRef` | [SecretKeySelector](#secretkeyselector) | SecretRef references the key in a Secret in the same namespace that holds the header value. The Secret must opt in to the credential Secret contract and list the origin of spec.url. | + +### CoderMCPServerAccess + +CoderMCPServerAccess lists the Coder groups that can read the server. + +| Field | Type | Description | +| --- | --- | --- | +| `groups` | string array | Groups are Coder group names in the server's organization. Everyone is the group of all organization members. An empty list leaves the server to organization admins. Coder reserves the names new and create. While a listed group does not exist, the controller removes the access of every group that this list does not name, adds none, disables the server if the spec disables it, and waits with GroupNotFound for the rest. | + +### CoderMCPServerAuth + +CoderMCPServerAuth is how Coder authenticates to the MCP server. Exactly +the block that matches type is set. ++kubebuilder:validation:XValidation:rule="(self.type == 'api_key') == has(self.apiKey)",message="auth.apiKey must be set exactly when auth.type is api_key" ++kubebuilder:validation:XValidation:rule="(self.type == 'custom_headers') == has(self.customHeaders)",message="auth.customHeaders must be set exactly when auth.type is custom_headers" ++kubebuilder:validation:XValidation:rule="(self.type == 'oauth2') == has(self.oauth2)",message="auth.oauth2 must be set exactly when auth.type is oauth2" + +| Field | Type | Description | +| --- | --- | --- | +| `type` | string | Type is the auth type. Coder's user_oidc type is not supported, because it sends each user's login token to the server URL. | +| `apiKey` | [CoderMCPServerAPIKeyAuth](#codermcpserverapikeyauth) | APIKey sends one Secret value in one header. | +| `customHeaders` | [CoderMCPServerHeader](#codermcpserverheader) array | CustomHeaders sends each Secret value in its own header. | +| `oauth2` | [CoderMCPServerOAuth2Auth](#codermcpserveroauth2auth) | OAuth2 lets each user connect with an OAuth2 client that the admin registered at the authorization server. | + +### CoderMCPServerCredentialStatus + +CoderMCPServerCredentialStatus records which Secret value was last sent to +Coder for one credential field. It holds no secret data and nothing derived +from it. + +| Field | Type | Description | +| --- | --- | --- | +| `field` | string | Field names the credential: apiKey, oauth2ClientSecret, or `header:`. | +| `secretName` | string | SecretName is the Secret name that was read. | +| `secretKey` | string | SecretKey is the key in the Secret that was read. | +| `secretUID` | string | SecretUID is the UID of the Secret that was read. | +| `secretResourceVersion` | string | SecretResourceVersion is the resourceVersion of the Secret that was read. | + +### CoderMCPServerHeader + +CoderMCPServerHeader is one header whose value comes from a Secret. + +| Field | Type | Description | +| --- | --- | --- | +| `name` | string | Name is the HTTP header name. | +| `secretRef` | [SecretKeySelector](#secretkeyselector) | SecretRef references the key in a Secret in the same namespace that holds the header value. The Secret must opt in to the credential Secret contract and list the origin of spec.url. | + +### CoderMCPServerOAuth2Auth + +CoderMCPServerOAuth2Auth is a manually registered OAuth2 client. Coder's +automatic discovery and dynamic client registration are not supported. + +| Field | Type | Description | +| --- | --- | --- | +| `clientID` | string | ClientID is the OAuth2 client ID. | +| `clientSecretRef` | [SecretKeySelector](#secretkeyselector) | ClientSecretRef references the key in a Secret in the same namespace that holds the client secret. The Secret must opt in to the credential Secret contract and list the origins of spec.url, authURL, tokenURL and revocationURL. | +| `authURL` | string | AuthURL is the authorization endpoint that users' browsers open. | +| `tokenURL` | string | TokenURL is the token endpoint that Coder sends the client secret to. | +| `revocationURL` | string | RevocationURL is the https:// token revocation endpoint. Coder sends the client secret there when a user disconnects. | +| `scopes` | string array | Scopes are the OAuth2 scopes that Coder requests. | + +### SecretKeySelector + +SecretKeySelector identifies a key in a Secret. + +| Field | Type | Description | +| --- | --- | --- | +| `name` | string | Name is the Kubernetes Secret name. | +| `key` | string | Key is the key inside the Secret data map. | + +## Source + +- Go type: `api/v1alpha1/codermcpserver_types.go` +- Generated CRD: `config/crd/bases/coder.com_codermcpservers.yaml` diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index b24c9c18..a2f46bd1 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -63,16 +63,17 @@ Then open `http://127.0.0.1:3000` in a browser. ## 5. Clean up (optional) -Delete the `CoderAIProvider` and `CoderAIGatewayKey` objects first, if you created any, and then the control plane, while the operator still runs. The operator then deletes the providers and keys in Coder, cleans up, and removes its finalizers. A provider or key deleted after its control plane stays in Coder. A `CoderAIGatewayKey` can take up to 5 minutes after its last key create. After that, remove the bundle. The commands set `BASE` again, because you can be in a new shell. +Delete the `CoderAIProvider`, `CoderAIGatewayKey` and `CoderMCPServer` objects first, if you created any, and then the control plane, while the operator still runs. The operator then deletes the providers, keys and MCP servers in Coder, cleans up, and removes its finalizers. A provider, key or MCP server deleted after its control plane stays in Coder. A `CoderAIGatewayKey` can take up to 5 minutes after its last key create. After that, remove the bundle. The commands set `BASE` again, because you can be in a new shell. !!! warning - Delete all other `CoderAIGatewayKey`, `CoderAIProvider`, `CoderControlPlane`, `CoderProvisioner`, and `CoderWorkspaceProxy` resources before you delete the bundle. Delete `CoderAIProvider` and `CoderAIGatewayKey` resources before their control plane. When you delete the bundle, you also delete the `coder.com` CRDs, and Kubernetes then removes all remaining resources of these kinds in the cluster. When you delete the `coder` namespace, you also delete all other objects in it, for example Secrets and PersistentVolumeClaims. + Delete all other `CoderAIGatewayKey`, `CoderAIProvider`, `CoderControlPlane`, `CoderMCPServer`, `CoderProvisioner`, and `CoderWorkspaceProxy` resources before you delete the bundle. Delete `CoderAIProvider`, `CoderAIGatewayKey` and `CoderMCPServer` resources before their control plane. When you delete the bundle, you also delete the `coder.com` CRDs, and Kubernetes then removes all remaining resources of these kinds in the cluster. When you delete the `coder` namespace, you also delete all other objects in it, for example Secrets and PersistentVolumeClaims. ```bash BASE="https://raw.githubusercontent.com/coder/coder-k8s/main" kubectl delete coderaiproviders --all -n coder --wait --timeout=120s kubectl delete coderaigatewaykeys --all -n coder --wait --timeout=420s +kubectl delete codermcpservers --all -n coder --wait --timeout=120s kubectl delete -f "$BASE/config/samples/coder_v1alpha1_codercontrolplane.yaml" --ignore-not-found kubectl wait --for=delete codercontrolplane/codercontrolplane-sample -n coder --timeout=120s kubectl delete -f "$BASE/dist/install.yaml" --ignore-not-found diff --git a/hack/update-manifests.sh b/hack/update-manifests.sh index 841e7a4f..c205c5e0 100755 --- a/hack/update-manifests.sh +++ b/hack/update-manifests.sh @@ -31,7 +31,7 @@ list_manifests() { # CRDs of kinds whose controller is not registered yet. They go to config/crd/dormant/, which no kustomization, # example, doc or script applies, so the kind is never served without its controller. # Move a file back by deleting its entry when the controller ships. -DORMANT_CRDS=(coder.com_codermcpservers.yaml coder.com_coderworkspacepools.yaml coder.com_coderworkspacepoolmembers.yaml coder.com_coderworkspaceclaims.yaml) +DORMANT_CRDS=(coder.com_coderworkspacepools.yaml coder.com_coderworkspacepoolmembers.yaml coder.com_coderworkspaceclaims.yaml) # RBAC manifests that only the aggregated API server needs; the controller-only install bundle leaves them out. BUNDLE_EXCLUDED_RBAC=(apiservice-cabundle-role.yaml auth-delegator-binding.yaml authentication-reader-binding.yaml) diff --git a/internal/app/controllerapp/controllerapp.go b/internal/app/controllerapp/controllerapp.go index d0ca9b11..defca81c 100644 --- a/internal/app/controllerapp/controllerapp.go +++ b/internal/app/controllerapp/controllerapp.go @@ -144,6 +144,14 @@ func SetupControllers(mgr manager.Manager) error { return fmt.Errorf("unable to create AI Gateway key controller: %w", err) } + mcpServerReconciler := &controller.CoderMCPServerReconciler{ + Client: client, + Scheme: managerScheme, + } + if err := mcpServerReconciler.SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create MCP server controller: %w", err) + } + return nil } diff --git a/internal/controller/codermcpserver_controller.go b/internal/controller/codermcpserver_controller.go index 11723133..cf3c838a 100644 --- a/internal/controller/codermcpserver_controller.go +++ b/internal/controller/codermcpserver_controller.go @@ -46,10 +46,14 @@ const ( mcpServerNoncePrefix = "coder-k8s:" ) -// CoderMCPServerReconciler reconciles CoderMCPServer objects (#155). It is not -// registered yet. It checks the control plane, the organization and its -// opt-in, and the credential Secrets, reports a slug that a config of -// someone else holds, creates the config, and syncs it on every reconcile. +// +kubebuilder:rbac:groups=coder.com,resources=codermcpservers,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=coder.com,resources=codermcpservers/status,verbs=get;update +// +kubebuilder:rbac:groups=coder.com,resources=codermcpservers/finalizers,verbs=update + +// CoderMCPServerReconciler reconciles CoderMCPServer objects (#155). It checks +// the control plane, the organization and its opt-in, and the credential +// Secrets, reports a slug that a config of someone else holds, creates the +// config, and syncs it on every reconcile. type CoderMCPServerReconciler struct { client.Client Scheme *runtime.Scheme @@ -78,9 +82,9 @@ func mcpServerStalled(requeue time.Duration, reason, format string, args ...any) return &mcpServerStep{stalled: true, reason: reason, message: fmt.Sprintf(format, args...), requeue: requeue} } -// SetupWithManager wires the reconciler into controller-runtime. Nothing calls -// it until activation. The controller watches its own kind without a -// generation predicate, so an annotation change also triggers a reconcile. +// SetupWithManager wires the reconciler into controller-runtime. The +// controller watches its own kind without a generation predicate, so an +// annotation change also triggers a reconcile. // A change of the referenced control plane or of a credential Secret requeues // the object. func (r *CoderMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/internal/controller/codermcpserver_controller_test.go b/internal/controller/codermcpserver_controller_test.go index 142f9c3a..016a160d 100644 --- a/internal/controller/codermcpserver_controller_test.go +++ b/internal/controller/codermcpserver_controller_test.go @@ -341,6 +341,19 @@ func TestCoderMCPServerControlPlaneAndOptIn(t *testing.T) { } } + // The gate compares resolved IDs, not names: a listed name that Coder + // resolves to the object's organization ID allows the object. + env.fake.addOrganization("alias") + env.setOptIn(ctx, t, &coderv1alpha1.MCPServersSpec{Organizations: []string{"alias"}}) + env.fake.failNext(routeOrganization, fakeFault{Rewrite: func(answer any) any { return answer }}) // The object's own organization. + env.fake.failNext(routeOrganization, fakeFault{Rewrite: func(answer any) any { + org := answer.(codersdk.Organization) + org.ID = env.orgID + return org + }}) + got, _ = env.reconcile(ctx, t, server) + requireMCPServerStep(t, got, false, "Applied") + // A failed lookup of a listed name is a wait, not a refusal. env.setOptIn(ctx, t, &coderv1alpha1.MCPServersSpec{Organizations: []string{"other", "coder"}}) env.fake.failNext(routeOrganization, fakeFault{Rewrite: func(answer any) any { return answer }}) // The object's own organization. diff --git a/internal/controller/controllerapp_setup_test.go b/internal/controller/controllerapp_setup_test.go index f9144ba7..3e936199 100644 --- a/internal/controller/controllerapp_setup_test.go +++ b/internal/controller/controllerapp_setup_test.go @@ -6,11 +6,16 @@ import ( "time" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" "github.com/coder/coder-k8s/internal/app/controllerapp" ) @@ -18,7 +23,8 @@ import ( // against the envtest API server: no field index is registered twice, every // watched kind is served, and the caches sync. The test runs serially, and // its cache sees only an empty namespace, so its reconcilers never touch -// the objects of other tests. +// the objects of other tests. A CoderMCPServer in that namespace gets its +// finalizer and a status from the registered controller. func TestSetupControllersStartsEveryController(t *testing.T) { ctx := context.Background() namespace := createTestNamespace(ctx, t, "setup") @@ -41,6 +47,25 @@ func TestSetupControllersStartsEveryController(t *testing.T) { syncCtx, syncCancel := context.WithTimeout(ctx, 30*time.Second) defer syncCancel() require.True(t, mgr.GetCache().WaitForCacheSync(syncCtx), "the caches sync") + + server := &coderv1alpha1.CoderMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "github", Namespace: namespace}, Spec: validMCPServerSpec()} + require.NoError(t, k8sClient.Create(ctx, server)) + t.Cleanup(func() { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(server), server) + server.Finalizers = nil + _ = k8sClient.Update(context.Background(), server) + _ = k8sClient.Delete(context.Background(), server) + }) + require.Eventually(t, func() bool { + got := &coderv1alpha1.CoderMCPServer{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(server), got); err != nil { + return false + } + ready := meta.FindStatusCondition(got.Status.Conditions, coderv1alpha1.CoderMCPServerConditionReady) + return controllerutil.ContainsFinalizer(got, coderv1alpha1.CoderMCPServerCleanupFinalizer) && + ready != nil && ready.Reason == "ControlPlaneNotReady" + }, 30*time.Second, 100*time.Millisecond, "the CoderMCPServer controller reconciles the object") + cancel() require.NoError(t, <-done) } diff --git a/mkdocs.yml b/mkdocs.yml index 62e3d4bd..9103d0d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Test a template version: how-to/test-templates.md - Manage AI providers: how-to/manage-ai-providers.md - Manage AI Gateway keys: how-to/manage-ai-gateway-keys.md + - Manage MCP servers: how-to/manage-mcp-servers.md - Gate template promotion with GitOps: how-to/gitops.md - Run the MCP server: how-to/mcp-server.md - Troubleshooting: how-to/troubleshooting.md @@ -55,6 +56,7 @@ nav: - CoderAIGatewayKey: reference/api/coderaigatewaykey.md - CoderAIProvider: reference/api/coderaiprovider.md - CoderControlPlane: reference/api/codercontrolplane.md + - CoderMCPServer: reference/api/codermcpserver.md - CoderProvisioner: reference/api/coderprovisioner.md - CoderTemplateTest: reference/api/codertemplatetest.md - CoderWorkspaceProxy: reference/api/coderworkspaceproxy.md