Skip to content

🤖 feat: disable the CoderMCPServer config when the control plane withdraws its opt-in - #360

Merged
ThomasK33 merged 6 commits into
mainfrom
mcp-optin-disable
Oct 14, 2026
Merged

ThomasK33 merged 6 commits into
mainfrom
mcp-optin-disable

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

When the control plane withdraws its CoderMCPServer opt-in, the dormant reconciler now disables the config that the object created in Coder, with one PATCH that holds only enabled: false. It reads no credential. The organization lookup also trusts only Coder's own 404 now, and MCPServersNotAllowed resyncs on a timer. The kind is not registered, so users see no change.

Fixes #336
Fixes #358

Background

How the opt-in disable works

When the scope gate stalls with MCPServersNotAllowed or ForceOnNotAllowed and status.configID is set, the reconciler runs disableMCPServerWithoutOptIn. Any other stall of the scope gate, such as a mismatched Coder answer for a listed name, keeps the config as it is. The opt-in stalls come from three cases:

  • spec.mcpServers is removed (MCPServersNotAllowed)
  • it no longer lists the organization (MCPServersNotAllowed)
  • it no longer sets allowForceOn for a force_on server (ForceOnNotAllowed)

The disable path:

  1. Reads the config by ID. Coder's own 404 (CoderMCPServer: trust only Coder's own 404 before clearing the config ID (gate before M9) #337) or an already disabled config stalls with no write. Both also record the generation as applied: the disable answer can be lost after Coder committed it, and a restored opt-in must not apply a pending generation that enables the config, or create a gone config again, before a spec edit. An answer for another config gives CoderAnswerMismatch. A failed read waits 30 s with "Coder can still use MCP server config".
  2. Sends PATCH {"enabled": false}, with no credential and no destination field. A failed PATCH, or an answer for another or an enabled config, waits 30 s and retries.
  3. Before the PATCH, writes appliedGeneration to status. Coder can commit the PATCH while its answer or the next status write is lost, and a pending generation must not enable the config once the opt-in comes back. A failed status write sends no PATCH. The consent disable of 🤖 feat: disable the CoderMCPServer config when a credential Secret withdraws consent #330 uses the same step.
  4. Like the consent disable, The disable is a kill switch: when the opt-in comes back, the sync reports DisabledExternally and writes nothing until a spec edit.

The scope gate runs before the credential read, so this path never reads a Secret.

When the organization name no longer resolves (OrganizationNotFound) or now resolves to another ID (OrganizationChanged), the config stays in the pinned organization. The reconciler then runs the same opt-in check with the pinned ID from status.organizationID, and a withdrawn opt-in still disables the config there. A name that is still listed changes nothing, and the original step is reported. The read and the PATCH are now shared helpers (readMCPServerToDisable, sendMCPServerDisable) used by both the consent disable and the opt-in disable. An object without a config ID still sends no MCP request (plan S17).

API docs

The field docs of spec.mcpServers, organizations and allowForceOn now describe the fail-closed disable: a removed organization, a removed spec.mcpServers or a withdrawn allowForceOn disables the configs that CoderMCPServer objects created, with one request that holds only enabled: false, and a spec edit enables a server again. The CRD, dist/install.yaml and docs/reference/api/codercontrolplane.md are regenerated.

The status.appliedGeneration field doc now says that a kill switch also sets it, before the disable is sent, and that Ready tells whether Coder holds the spec. The dormant CoderMCPServer CRD is regenerated.

#358

  • resolveMCPServerOrganization uses isCoderResourceNotFound. Any other 404 waits 30 s with CoderUnavailable, both for spec.organization and for a listed name.
  • Both MCPServersNotAllowed stalls resync every 10 minutes. A listed name can resolve later, when it is created or renamed in Coder, and no Kubernetes event follows.
  • The fake Coder now answers an unknown organization with Coder's own 404 message (coderd/httpmw/organizationparam.go:94).

Review record

Codex round 1 (normal review of de72316) found three items, all fixed in a5dd49ac: record the generation when the config is found disabled, disable only on opt-in stalls, and update the API field docs. The security review of de72316 found no issues.

Codex round 2 (normal review of a5dd49a) found two items, both fixed in 7c187d5c: write the disable marker before the PATCH (moved here from the planned M6f), and disable through the pinned organization when its name no longer resolves. The security review of a5dd49a found no issues. Before that fix, I merged main into this branch (merge commit 531c4712) because #346 had changed the regenerated files.

Codex round 3 (normal review of 7c187d5) found two items, both fixed in 1dacbde9: record the generation when the disable read finds the config gone, and document the kill-switch meaning of status.appliedGeneration. The security review of 7c187d5 found no issues.

CI's first test run on de72316 failed while downloading Go modules (proxy.golang.org stream error), before any test ran.

Validation

  • TestCoderMCPServerDisablesWithoutOptIn, for each of the three withdrawals:
    • a failed disable PATCH waits 30 s with "Coder can still use MCP server config"
    • the next reconcile sends exactly one write, {"enabled":false}, and stalls with the scope reason. Coder holds the same state with only enabled changed.
    • two more reconciles find the config disabled and write nothing
    • an interceptor counts zero reads of the credential Secret over the whole sequence
    • the restored opt-in gives DisabledExternally with no write. A spec edit applies and enables the config.
  • TestCoderMCPServerOptInWithdrawnWithoutConfig: with no config ID, no MCP request, and a 10-minute requeue.
  • TestCoderMCPServerOrganizationNotCoder404: an HTML 404 and Route not found., on the lookup of the object's organization and of a listed name, each give a CoderUnavailable wait (30 s) and no MCP request. The next reconcile gives MCPServersNotAllowed with a 10-minute requeue.
  • TestCoderMCPServerOptInFoundDisabledHolds: with a pending generation, the opt-in disable PATCH commits but answers 502. The next reconcile finds the config disabled and records the generation. The restored opt-in gives DisabledExternally with no write, and the pending description is not applied.
  • TestCoderMCPServerOptInDisableMarkerFirst: with a pending generation, the disable PATCH commits but answers 502, and status already holds the current appliedGeneration. The opt-in comes back before any other reconcile, and the sync gives DisabledExternally with no write and the pending description not applied. When the status write before the PATCH fails, the reconcile returns the error and sends no PATCH.
  • TestCoderMCPServerOptInUsesPinnedOrganization: Coder's own 404, or an answer with another ID, for the object's organization, with the opt-in removed, gives exactly one {"enabled":false} PATCH through the pinned ID. With the name still listed, the reconcile reports OrganizationNotFound or OrganizationChanged and writes nothing.
  • TestCoderMCPServerOptInGoneConfigHolds: with a pending generation, the config is deleted in Coder and the opt-in is withdrawn. The disable path stalls with "does not exist in Coder" and records the generation. The restored opt-in gives DeletedExternally with no create. A spec edit then creates the config again.
  • TestCoderMCPServerScopeMismatchKeepsConfig: a mismatched Coder answer for a listed name gives CoderAnswerMismatch, no MCP request, and the config stays enabled.
  • TestCoderMCPServerControlPlaneAndOptIn and TestCoderMCPServerWatches now expect the disable and the re-arm by spec edit.
  • Mutation checks: each of these changes makes a test fail: no opt-in disable hook, isCoderNotFound on the organization lookup, no timed requeue for an unlisted organization, no generation recorded for a config found disabled, a disable on any stall of the scope gate, no status write before the disable PATCH, an ignored error of that write, no pinned-organization fallback, and no generation recorded for a gone config.
  • 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.

Size

575 lines added and 60 removed. 44 changed lines are regenerated (CRDs, dist/install.yaml, API reference). The rest are hand-written: 126 changed lines in codermcpserver_disable.go (most of them move the read and the PATCH into shared helpers), 52 in the reconciler, 6 in the fake Coder, 369 in the new tests, 17 in existing tests, and 21 in the API field docs. The three review rounds added 307 of these lines, most of them tests. Without generated code and tests it is 205 changed lines.

Refs #155


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $171.92

…draws its opt-in

When spec.mcpServers is removed, no longer lists the organization, or no
longer allows force_on for a force_on server, the controller now
disables a config that the object created, with one PATCH that holds
only enabled: false. It reads no credential. The disable is a kill
switch, so a restored opt-in needs a spec edit to enable the config. The
read and the PATCH are shared with the consent disable.

Fixes #336
Refs #155

Change-Id: Iebea23e388d9c43540daebf8380f256c59f282cf
Signed-off-by: Thomas Kosiewski <tk@coder.com>
…lookup and resync MCPServersNotAllowed

The organization lookup treated any 404 as a missing organization. A
proxy page or "Route not found." for a listed name then skipped that
name, so the scope check stalled with MCPServersNotAllowed, which had no
timed requeue. The lookup now uses isCoderResourceNotFound, so another
404 waits with CoderUnavailable, and MCPServersNotAllowed resyncs every
10 minutes. The fake answers an unknown organization like Coder.

Fixes #358
Refs #155

Change-Id: I90fa60c56e9f867d4c7c53bce35c1be49be366a8
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T13:20:51.999210Z 1dacbde Manual request
🔒 Security Review ✅ Completed 2026-10-14T13:18:28.558216Z 1dacbde Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: de723169c0

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de723169c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/codermcpserver_disable.go
Comment thread internal/controller/codermcpserver_controller.go Outdated
Comment thread internal/controller/codermcpserver_disable.go Outdated
…it the opt-in disable to opt-in stalls, and document it

- A read that finds the config already disabled now records the
  generation, so a lost disable answer and a restored opt-in do not apply
  a pending generation that enables the config.
- Only MCPServersNotAllowed and ForceOnNotAllowed run the opt-in disable.
  Another stall of the scope check, such as a mismatched Coder answer,
  keeps the config.
- The spec.mcpServers field docs describe the fail-closed disable.

Refs #155

Change-Id: If9ea006bcd30e178dc579172f03ddce31b0b3923
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5dd49ac2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/codermcpserver_disable.go Outdated
Comment thread internal/controller/codermcpserver_controller.go Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: a5dd49ac2b

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Change-Id: Ib67e349731c2986ff930b25276e6ba3ed6671b2c
Signed-off-by: Thomas Kosiewski <tk@coder.com>
…isable through the pinned organization

- The opt-in and consent disables write appliedGeneration to status
  before the disable PATCH, and a failed status write sends no PATCH.
  A lost disable answer followed by a restored opt-in then gives
  DisabledExternally instead of applying a pending generation.
- When the organization name no longer resolves (OrganizationNotFound)
  or resolves to another ID (OrganizationChanged), a withdrawn opt-in
  still disables the config through the pinned organization ID.

Refs #155

Change-Id: Ie54facd7656489a5436d639734912e3c5c26050b
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 7c187d5cca

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c187d5cca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/codermcpserver_disable.go
Comment thread internal/controller/codermcpserver_disable.go
… and document appliedGeneration

- A fail-closed disable that finds the config gone (Coder's own 404)
  now records the generation, so a restored opt-in or consent gives
  DeletedExternally instead of creating an enabled config before a
  spec edit.
- The status.appliedGeneration doc now says that a kill switch also
  sets it, before the disable is sent. The dormant CRD is regenerated.

Refs #155

Change-Id: I781f44dfea54d7834faf712e75d4fe764f65323f
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 1dacbde9c3

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1dacbde9c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/codermcpserver_disable.go
github-merge-queue Bot pushed a commit that referenced this pull request Oct 14, 2026
… consent check, and test the consent disable marker (#363)

## Summary

The dormant `CoderMCPServer` consent check now requires https for the
OAuth2 revocation URL that Coder holds, as the spec does. This PR also
adds the consent-path tests for the disable marker that #360 introduced.
The kind is not registered, so users see no change.

**Base branch:** `main`. #360 landed as `69c18dd5`. I merged `main` into
this branch with a merge commit, and the diff against `main` is the
reviewed M6f change only.

Fixes #311
Fixes #334

## Background

- #311 (plan gate G8): #330 disables the config when a credential Secret
withdraws consent. #311 stayed open for one case: with a pending
generation, a disable whose answer or status write is lost, followed by
restored consent, applied the pending generation and enabled the config
without a spec edit. #360 fixes this for both fail-closed paths: the
controller writes `appliedGeneration` to status before the disable
PATCH, and a read that finds the config disabled or gone also records
the generation. This PR adds the tests for the consent path.
- #334: Coder accepts an `http` revocation URL for loopback hosts, while
the CRD and the controller require `https` for
`spec.auth.oauth2.revocationURL`. The consent check of the disable path
checked Coder's revocation URL only against the Secret's origins, so a
dashboard-set `http` loopback revocation URL that the Secret listed
passed the check.

## Implementation

- `mcpServerConsentHolds` returns false when Coder's revocation URL is
not `https` (4 lines), so the config is disabled.
- New tests in `internal/controller/codermcpserver_marker_test.go`.

## Validation

- `TestCoderMCPServerLostDisableAnswerHolds` (#311): with a pending
generation, the Secret is deleted, and the disable PATCH commits but
answers 502. Status already holds the current `appliedGeneration`. The
Secret comes back before any other reconcile, and the sync gives
`DisabledExternally` with no write and the pending description not
applied. A spec edit after the disable enables the config.
- `TestCoderMCPServerDisableNeedsTheMarker` (#311): when the status
write before the disable PATCH fails, the reconcile returns the error
and sends no PATCH.
- `TestCoderMCPServerFoundDisabledHolds` (#311): with a pending
generation and a deleted Secret, Coder already holds the disable (a lost
answer). The disable path records the generation, and the restored
Secret gives `DisabledExternally` with no write.
- `TestCoderMCPServerConsentCheckNeedsHTTPSRevocation` (#334): Coder
holds `http://127.0.0.1:8080/revoke`, and the Secret lists that origin.
A spec URL edit that fails the credential read gives exactly one
`{"enabled":false}` PATCH.
- Mutation check: without the https check, the #334 test fails. The
marker mutants are in #360.
- 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.

## Size

158 lines added, all hand-written: 4 in `codermcpserver_disable.go` and
154 in the new tests.

Refs #155

---

_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$171.92`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=171.92 -->

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant