-
Notifications
You must be signed in to change notification settings - Fork 158
feat(mcp-servers): add server selection and GitHub auth #1083
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
DevelopmentCats
merged 4 commits into
coder:main
from
Edd88-pixel:eddy/mcp-server-selection-888
Aug 27, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
985aa73
feat(mcp-servers): add popular server selection
Edd88-pixel edb65d9
fix(mcp-servers): improve configuration diagnostics
Edd88-pixel 2ac43c1
feat(mcp-servers): add GitHub authentication modes
Edd88-pixel fa31628
Merge branch 'main' into eddy/mcp-server-selection-888
DevelopmentCats File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| --- | ||
| display_name: MCP Servers | ||
| description: Select and configure popular official MCP servers for multiple workspace agents. | ||
| icon: ../../../../.icons/mcp.svg | ||
| verified: false | ||
| tags: [mcp, ai, agent, helper] | ||
| --- | ||
|
|
||
| # MCP Servers | ||
|
|
||
| Adds a multi-select field to the workspace creation form and configures each selected MCP server for every requested agent client. The module uses [mcp-add](https://github.com/paoloricciuti/mcp-add) to preserve each client's native configuration format. | ||
|
|
||
| The multi-select workspace parameter requires Coder 2.24 or newer. | ||
|
|
||
| ```tf | ||
| module "mcp_servers" { | ||
| source = "registry.coder.com/coder/mcp-servers/coder" | ||
| version = "0.0.1" | ||
|
|
||
| agent_id = coder_agent.main.id | ||
| clients = ["claude code", "codex"] | ||
| } | ||
| ``` | ||
|
|
||
| The initial catalog includes the official [GitHub MCP Server](https://github.com/github/github-mcp-server) and [Playwright MCP](https://github.com/microsoft/playwright-mcp). | ||
|
|
||
| ## GitHub authentication | ||
|
|
||
| By default, the module configures the GitHub MCP endpoint without credentials. This preserves the original behavior and lets clients with compatible OAuth support authenticate themselves. | ||
|
|
||
| For Claude Code, Codex, and Cursor, `token-env` references a workspace environment variable without writing its value to any MCP configuration file: | ||
|
|
||
| ```tf | ||
| module "mcp_servers" { | ||
| source = "registry.coder.com/coder/mcp-servers/coder" | ||
| version = "0.0.1" | ||
|
|
||
| agent_id = coder_agent.main.id | ||
| clients = ["claude code", "codex", "cursor"] | ||
| default = ["github"] | ||
|
|
||
| github_auth = { | ||
| mode = "token-env" | ||
| token_env_var = "GITHUB_MCP_TOKEN" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Template authors can inject a shared value through the agent environment. Everyone with shell access to the workspace can read it, and rotation requires updating the template-provided value and restarting the workspace. | ||
|
|
||
| For per-user values, create an enabled [Coder User Secret](https://coder.com/docs/user-guides/user-secrets) targeting the same variable. Provide the value over standard input so it does not appear in shell history or process arguments: | ||
|
|
||
| ```sh | ||
| echo -n "$GITHUB_TOKEN" | coder secret create github-mcp --env GITHUB_MCP_TOKEN | ||
| ``` | ||
|
|
||
| Coder applies a new, modified, disabled, or re-enabled secret on the next workspace start. If the required variable is absent, the module fails with an actionable message instead of reporting authenticated success. | ||
|
|
||
| ### Coder External Auth | ||
|
|
||
| Claude Code can resolve a short-lived token at connection time with `coder external-auth access-token`. The module creates a `headersHelper` under `$HOME/.coder-modules/coder/mcp-servers/scripts/`; it never stores the returned token. | ||
|
|
||
| ```tf | ||
| github_auth = { | ||
| mode = "external-auth" | ||
| external_auth_id = "primary-github" | ||
| } | ||
| ``` | ||
|
|
||
| This mode intentionally accepts Claude Code only. Codex's HTTP helper cannot set the reserved `Authorization` header, and Cursor has no documented dynamic-header helper, so those combinations fail during Terraform planning. Use `token-env` with a User Secret for Codex or Cursor. | ||
|
|
||
| ### Native GitHub OAuth | ||
|
|
||
| `native-oauth` replaces the remote GitHub entry with the official local stdio server for every selected client. The default image is versioned and pinned by digest. The callback port is published on loopback only, and the server can fall back to GitHub's device flow in a headless workspace. | ||
|
|
||
| ```tf | ||
| github_auth = { | ||
| mode = "native-oauth" | ||
| oauth_callback_port = 8085 | ||
| } | ||
| ``` | ||
|
|
||
| This opt-in mode requires Docker inside the workspace. The official server keeps its OAuth token in memory; the module does not create a token file. A PAT still takes precedence if the local server receives `GITHUB_PERSONAL_ACCESS_TOKEN` from the workspace environment. | ||
|
|
||
| The hosted GitHub MCP endpoint currently documents PAT authentication for Claude Code and Cursor. If Claude Code reports that dynamic client registration is unsupported, use `token-env`, Coder External Auth, or the local native OAuth mode instead of retrying the incompatible flow. Follow the [official client-specific authentication guide](https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/README.md) for client details. | ||
|
|
||
| The selection is immutable for the lifetime of a workspace because removing a server from the field cannot safely remove configuration that the user may have customized. Rebuild the workspace to change the selection. | ||
|
|
||
| When a non-empty Codex configuration already exists, the module validates it with the installed Codex CLI before making changes. Ensure the Codex module runs first; malformed TOML is left unchanged with an actionable error. | ||
|
|
||
| For a prebuilt workspace, the initial `prebuilds` owner receives only the unauthenticated MCP structure. User Secret injection, External Auth resolution, and OAuth startup are skipped. Coder reruns Terraform with the final owner during claim, and the authentication post-install script is then recalculated for that owner. | ||
|
|
||
| ## Preselect servers | ||
|
|
||
| Template authors can preselect one or both servers while still allowing users to change the choice when creating a workspace. | ||
|
|
||
| ```tf | ||
| module "mcp_servers" { | ||
| source = "registry.coder.com/coder/mcp-servers/coder" | ||
| version = "0.0.1" | ||
|
|
||
| agent_id = coder_agent.main.id | ||
| clients = ["gemini", "windsurf"] | ||
| default = ["github", "playwright"] | ||
| } | ||
| ``` | ||
|
|
||
| The module reuses Node.js 18 or newer when available. Otherwise it downloads a pinned Node.js runtime into `$HOME/.coder-modules/coder/mcp-servers/dependencies`, verifies the official SHA-256 checksum, and keeps the runtime isolated to this module. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import { beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test"; | ||
| import { | ||
| execContainer, | ||
| findResourceInstance, | ||
| removeContainer, | ||
| runContainer, | ||
| runTerraformApply, | ||
| runTerraformInit, | ||
| type TerraformState, | ||
| } from "~test"; | ||
|
|
||
| setDefaultTimeout(60 * 1000); | ||
|
|
||
| const clients = JSON.stringify(["claude code", "codex", "cursor"]); | ||
| const github = JSON.stringify(["github"]); | ||
|
|
||
| const mockCommands = ` | ||
| mkdir -p /usr/local/bin "$HOME/.cursor" "$HOME/.codex" | ||
| cat > /usr/local/bin/coder <<'EOF' | ||
| #!/bin/sh | ||
| if [ "$1" = "external-auth" ] && [ "$2" = "access-token" ]; then | ||
| printf '%s\n' 'dynamic-test-token' | ||
| fi | ||
| exit 0 | ||
| EOF | ||
| cat > /usr/local/bin/codex <<'EOF' | ||
| #!/bin/sh | ||
| printf '%s\n' "$*" >> /tmp/codex-calls | ||
| exit 0 | ||
| EOF | ||
| cat > /usr/local/bin/npx <<'EOF' | ||
| #!/bin/sh | ||
| printf 'npx %s\\n' "$*" | ||
| EOF | ||
| cat > /usr/local/bin/docker <<'EOF' | ||
| #!/bin/sh | ||
| printf 'docker %s\\n' "$*" | ||
| EOF | ||
| chmod +x /usr/local/bin/coder /usr/local/bin/codex /usr/local/bin/docker /usr/local/bin/npx | ||
| printf '%s\n' '{"mcpServers":{"github":{"type":"http","url":"https://api.githubcopilot.com/mcp/"}}}' > "$HOME/.claude.json" | ||
| cp "$HOME/.claude.json" "$HOME/.cursor/mcp.json" | ||
| printf '%s\n' '[mcp_servers.github]' 'url = "https://api.githubcopilot.com/mcp/"' > "$HOME/.codex/config.toml" | ||
| `; | ||
|
|
||
| async function executePipeline( | ||
| state: TerraformState, | ||
| options: { after?: string; beforeAuth?: string; env?: string[] } = {}, | ||
| ) { | ||
| const container = await runContainer("node:22-bookworm"); | ||
| const output: string[] = []; | ||
|
|
||
| try { | ||
| const setup = await execContainer(container, ["bash", "-c", mockCommands]); | ||
| expect(setup.exitCode).toBe(0); | ||
| if (options.beforeAuth) { | ||
| const preparation = await execContainer(container, [ | ||
| "bash", | ||
| "-c", | ||
| options.beforeAuth, | ||
| ]); | ||
| expect(preparation.exitCode).toBe(0); | ||
| } | ||
| for (const name of ["install_script", "post_install_script"]) { | ||
| const script = findResourceInstance(state, "coder_script", name).script; | ||
| const result = await execContainer( | ||
| container, | ||
| ["bash", "-c", script], | ||
| options.env?.flatMap((value) => ["--env", value]), | ||
| ); | ||
| output.push(result.stdout, result.stderr); | ||
| if (result.exitCode !== 0) { | ||
| return { exitCode: result.exitCode, output: output.join("\n") }; | ||
| } | ||
| } | ||
| if (options.after) { | ||
| const result = await execContainer(container, [ | ||
| "bash", | ||
| "-c", | ||
| options.after, | ||
| ]); | ||
| output.push(result.stdout, result.stderr); | ||
| return { exitCode: result.exitCode, output: output.join("\n") }; | ||
| } | ||
| return { exitCode: 0, output: output.join("\n") }; | ||
| } finally { | ||
| await removeContainer(container); | ||
| } | ||
| } | ||
|
|
||
| describe("mcp-servers authentication", () => { | ||
| beforeAll(async () => { | ||
| await runTerraformInit(import.meta.dir); | ||
| }); | ||
|
|
||
| it("renders the exact baseline install script for explicit mode none", async () => { | ||
| const variables = { agent_id: "test-agent", clients, default: github }; | ||
| const [implicit, explicit] = await Promise.all([ | ||
| runTerraformApply(import.meta.dir, variables), | ||
| runTerraformApply(import.meta.dir, { | ||
| ...variables, | ||
| github_auth: JSON.stringify({ mode: "none" }), | ||
| }), | ||
| ]); | ||
|
|
||
| expect( | ||
| findResourceInstance(explicit, "coder_script", "install_script").script, | ||
| ).toBe( | ||
| findResourceInstance(implicit, "coder_script", "install_script").script, | ||
| ); | ||
| expect( | ||
| explicit.resources.some((item) => item.name === "post_install_script"), | ||
| ).toBeFalse(); | ||
| }); | ||
|
|
||
| it("configures token references without writing the token value", async () => { | ||
| const state = await runTerraformApply(import.meta.dir, { | ||
| agent_id: "test-agent", | ||
| clients, | ||
| default: github, | ||
| github_auth: JSON.stringify({ | ||
| mode: "token-env", | ||
| token_env_var: "WORKSPACE_GITHUB_TOKEN", | ||
| }), | ||
| }); | ||
| const result = await executePipeline(state, { | ||
| env: ["WORKSPACE_GITHUB_TOKEN=super-secret-test-value"], | ||
| after: ` | ||
| set -e | ||
| node - <<'NODE' | ||
| const fs = require("node:fs"); | ||
| const claude = JSON.parse(fs.readFileSync(process.env.HOME + "/.claude.json")); | ||
| const cursor = JSON.parse(fs.readFileSync(process.env.HOME + "/.cursor/mcp.json")); | ||
| if (claude.mcpServers.github.headers.Authorization !== "Bearer \${WORKSPACE_GITHUB_TOKEN}") process.exit(1); | ||
| if (cursor.mcpServers.github.headers.Authorization !== "Bearer \${env:WORKSPACE_GITHUB_TOKEN}") process.exit(1); | ||
| NODE | ||
| grep -q -- '--bearer-token-env-var WORKSPACE_GITHUB_TOKEN' /tmp/codex-calls | ||
| ! grep -R 'super-secret-test-value' "$HOME/.claude.json" "$HOME/.cursor/mcp.json" "$HOME/.codex/config.toml" /tmp/codex-calls | ||
| `, | ||
| }); | ||
|
|
||
| expect(result.exitCode).toBe(0); | ||
| expect(result.output).not.toContain("super-secret-test-value"); | ||
| expect(result.output).toContain("no token value was written"); | ||
| }); | ||
|
|
||
| it("fails explicitly when the token environment variable is missing", async () => { | ||
| const state = await runTerraformApply(import.meta.dir, { | ||
| agent_id: "test-agent", | ||
| clients, | ||
| default: github, | ||
| github_auth: JSON.stringify({ | ||
| mode: "token-env", | ||
| token_env_var: "WORKSPACE_GITHUB_TOKEN", | ||
| }), | ||
| }); | ||
| const result = await executePipeline(state); | ||
|
|
||
| expect(result.exitCode).not.toBe(0); | ||
| expect(result.output).toContain( | ||
| "requires the WORKSPACE_GITHUB_TOKEN environment variable", | ||
| ); | ||
| expect(result.output).not.toContain("no token value was written"); | ||
| }); | ||
|
|
||
| it("resolves external auth only through Claude's runtime helper", async () => { | ||
| const state = await runTerraformApply(import.meta.dir, { | ||
| agent_id: "test-agent", | ||
| clients: JSON.stringify(["claude code"]), | ||
| default: github, | ||
| github_auth: JSON.stringify({ | ||
| mode: "external-auth", | ||
| external_auth_id: "primary-github", | ||
| }), | ||
| }); | ||
| const result = await executePipeline(state, { | ||
| after: ` | ||
| set -e | ||
| helper="$HOME/.coder-modules/coder/mcp-servers/scripts/github-headers.sh" | ||
| "$helper" > /tmp/github-headers.json | ||
| node -e 'const h=require("/tmp/github-headers.json");if(h.Authorization!=="Bearer dynamic-test-token")process.exit(1)' | ||
| rm /tmp/github-headers.json | ||
| grep -q 'headersHelper' "$HOME/.claude.json" | ||
| ! grep -R 'dynamic-test-token' "$helper" "$HOME/.claude.json" | ||
| `, | ||
| }); | ||
|
|
||
| expect(result.exitCode).toBe(0); | ||
| expect(result.output).not.toContain("dynamic-test-token"); | ||
| expect(result.output).toContain("primary-github"); | ||
| }); | ||
|
|
||
| it("reports the external auth action when the provider is not connected", async () => { | ||
| const state = await runTerraformApply(import.meta.dir, { | ||
| agent_id: "test-agent", | ||
| clients: JSON.stringify(["claude code"]), | ||
| default: github, | ||
| github_auth: JSON.stringify({ mode: "external-auth" }), | ||
| }); | ||
| const result = await executePipeline(state, { | ||
| beforeAuth: `cat > /usr/local/bin/coder <<'EOF' | ||
| #!/bin/sh | ||
| if [ "$1" = "external-auth" ] && [ "$2" = "access-token" ]; then | ||
| printf '%s' 'https://coder.example/external-auth/github' | ||
| exit 1 | ||
| fi | ||
| exit 0 | ||
| EOF | ||
| chmod +x /usr/local/bin/coder`, | ||
| after: `"$HOME/.coder-modules/coder/mcp-servers/scripts/github-headers.sh"`, | ||
| }); | ||
| expect(result.exitCode).not.toBe(0); | ||
| expect(result.output).toContain("Authenticate at: https://coder.example"); | ||
| expect(result.output).not.toContain("dynamic-test-token"); | ||
| }); | ||
|
|
||
| it("pins native OAuth to the official image and loopback callback", async () => { | ||
| const state = await runTerraformApply(import.meta.dir, { | ||
| agent_id: "test-agent", | ||
| clients, | ||
| default: github, | ||
| github_auth: JSON.stringify({ mode: "native-oauth" }), | ||
| }); | ||
| const result = await executePipeline(state); | ||
|
|
||
| expect(result.exitCode).toBe(0); | ||
| expect(result.output).toContain("-p 127.0.0.1:8085:8085"); | ||
| expect(result.output).toContain( | ||
| "github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699", | ||
| ); | ||
| expect(result.output).not.toContain("PERSONAL_ACCESS_TOKEN"); | ||
| }); | ||
|
|
||
| it("skips user authentication while the workspace owner is prebuilds", async () => { | ||
| const state = await runTerraformApply( | ||
| import.meta.dir, | ||
| { | ||
| agent_id: "test-agent", | ||
| clients: JSON.stringify(["claude code"]), | ||
| default: github, | ||
| github_auth: JSON.stringify({ mode: "external-auth" }), | ||
| }, | ||
| { CODER_WORKSPACE_OWNER: "prebuilds" }, | ||
| ); | ||
| const result = await executePipeline(state); | ||
|
|
||
| expect(result.exitCode).toBe(0); | ||
| expect(result.output).toContain("after claim"); | ||
| expect(result.output).not.toContain("dynamic-test-token"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.