Skip to content

Commit 2e246ad

Browse files
feat(mcp-servers): add server selection and GitHub auth (#1083)
## Why Workspace users currently have to configure popular MCP servers and their authentication separately for each agent client. A shared Coder module makes that choice available during workspace creation while keeping credentials out of rendered Terraform and MCP configuration files. ## Changes - Add a Coder 2.24+ multi-select parameter for GitHub MCP Server and Playwright MCP across the supported agent clients. - Configure selected servers through pinned `mcp-add`, Playwright MCP, and checksum-verified Node.js versions. - Add GitHub authentication modes for environment-backed tokens, Coder User Secrets, Claude Code runtime External Auth, and the official local GitHub OAuth server. - Keep token values out of client configuration files, logs, Terraform-rendered scripts, and process arguments. - Reject External Auth combinations that lack a documented dynamic authorization helper instead of reporting a false connection. - Skip user authentication for the `prebuilds` owner and recalculate it after workspace claim. - Pin the local GitHub MCP image by version and digest, bind its callback to loopback, and retain headless device-flow support. - Start the module at version `0.0.1` so its interface can evolve before `1.0.0`. - Add Terraform and container-based coverage for baseline compatibility, authentication, missing credentials, unsupported clients, prebuild isolation, and failure propagation. ## Validation - `terraform test` — 10 passed, 0 failed. - `./scripts/terraform_test_all.sh` — 17 dependency tests and 10 module tests passed. - `./scripts/ts_test_auto.sh` — 16 passed, 0 failed; 48 assertions. - `./scripts/terraform_validate.sh` — passed. - `terraform fmt -check -diff` — passed. - Targeted Prettier and rendered-template ShellCheck — passed. - Docker E2E: Claude Code, Codex, and Cursor retained the unauthenticated baseline; token references were loaded without persisting the real token; Claude Code and Cursor connected to authenticated GitHub MCP, and Codex reported bearer-token authentication for the generated entry. - Claude Code executed the runtime `headersHelper` contract for Coder External Auth, including the not-connected diagnostic path. - The pinned official GitHub MCP image completed real `initialize` and `tools/list` requests and selected device flow in a headless container. - Not run: a full Coder Premium prebuild claim with two real users, because no isolated licensed deployment was available. - Not completed: final interactive GitHub device-flow approval and callback, because that requires user authorization in a GitHub account. --------- Co-authored-by: DevCats <christofer@coder.com>
1 parent 61da9d6 commit 2e246ad

8 files changed

Lines changed: 1249 additions & 0 deletions

File tree

.icons/mcp.svg

Lines changed: 12 additions & 0 deletions
Loading
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
display_name: MCP Servers
3+
description: Select and configure popular official MCP servers for multiple workspace agents.
4+
icon: ../../../../.icons/mcp.svg
5+
verified: false
6+
tags: [mcp, ai, agent, helper]
7+
---
8+
9+
# MCP Servers
10+
11+
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.
12+
13+
The multi-select workspace parameter requires Coder 2.24 or newer.
14+
15+
```tf
16+
module "mcp_servers" {
17+
source = "registry.coder.com/coder/mcp-servers/coder"
18+
version = "0.0.1"
19+
20+
agent_id = coder_agent.main.id
21+
clients = ["claude code", "codex"]
22+
}
23+
```
24+
25+
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).
26+
27+
## GitHub authentication
28+
29+
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.
30+
31+
For Claude Code, Codex, and Cursor, `token-env` references a workspace environment variable without writing its value to any MCP configuration file:
32+
33+
```tf
34+
module "mcp_servers" {
35+
source = "registry.coder.com/coder/mcp-servers/coder"
36+
version = "0.0.1"
37+
38+
agent_id = coder_agent.main.id
39+
clients = ["claude code", "codex", "cursor"]
40+
default = ["github"]
41+
42+
github_auth = {
43+
mode = "token-env"
44+
token_env_var = "GITHUB_MCP_TOKEN"
45+
}
46+
}
47+
```
48+
49+
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.
50+
51+
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:
52+
53+
```sh
54+
echo -n "$GITHUB_TOKEN" | coder secret create github-mcp --env GITHUB_MCP_TOKEN
55+
```
56+
57+
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.
58+
59+
### Coder External Auth
60+
61+
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.
62+
63+
```tf
64+
github_auth = {
65+
mode = "external-auth"
66+
external_auth_id = "primary-github"
67+
}
68+
```
69+
70+
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.
71+
72+
### Native GitHub OAuth
73+
74+
`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.
75+
76+
```tf
77+
github_auth = {
78+
mode = "native-oauth"
79+
oauth_callback_port = 8085
80+
}
81+
```
82+
83+
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.
84+
85+
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.
86+
87+
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.
88+
89+
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.
90+
91+
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.
92+
93+
## Preselect servers
94+
95+
Template authors can preselect one or both servers while still allowing users to change the choice when creating a workspace.
96+
97+
```tf
98+
module "mcp_servers" {
99+
source = "registry.coder.com/coder/mcp-servers/coder"
100+
version = "0.0.1"
101+
102+
agent_id = coder_agent.main.id
103+
clients = ["gemini", "windsurf"]
104+
default = ["github", "playwright"]
105+
}
106+
```
107+
108+
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.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test";
2+
import {
3+
execContainer,
4+
findResourceInstance,
5+
removeContainer,
6+
runContainer,
7+
runTerraformApply,
8+
runTerraformInit,
9+
type TerraformState,
10+
} from "~test";
11+
12+
setDefaultTimeout(60 * 1000);
13+
14+
const clients = JSON.stringify(["claude code", "codex", "cursor"]);
15+
const github = JSON.stringify(["github"]);
16+
17+
const mockCommands = `
18+
mkdir -p /usr/local/bin "$HOME/.cursor" "$HOME/.codex"
19+
cat > /usr/local/bin/coder <<'EOF'
20+
#!/bin/sh
21+
if [ "$1" = "external-auth" ] && [ "$2" = "access-token" ]; then
22+
printf '%s\n' 'dynamic-test-token'
23+
fi
24+
exit 0
25+
EOF
26+
cat > /usr/local/bin/codex <<'EOF'
27+
#!/bin/sh
28+
printf '%s\n' "$*" >> /tmp/codex-calls
29+
exit 0
30+
EOF
31+
cat > /usr/local/bin/npx <<'EOF'
32+
#!/bin/sh
33+
printf 'npx %s\\n' "$*"
34+
EOF
35+
cat > /usr/local/bin/docker <<'EOF'
36+
#!/bin/sh
37+
printf 'docker %s\\n' "$*"
38+
EOF
39+
chmod +x /usr/local/bin/coder /usr/local/bin/codex /usr/local/bin/docker /usr/local/bin/npx
40+
printf '%s\n' '{"mcpServers":{"github":{"type":"http","url":"https://api.githubcopilot.com/mcp/"}}}' > "$HOME/.claude.json"
41+
cp "$HOME/.claude.json" "$HOME/.cursor/mcp.json"
42+
printf '%s\n' '[mcp_servers.github]' 'url = "https://api.githubcopilot.com/mcp/"' > "$HOME/.codex/config.toml"
43+
`;
44+
45+
async function executePipeline(
46+
state: TerraformState,
47+
options: { after?: string; beforeAuth?: string; env?: string[] } = {},
48+
) {
49+
const container = await runContainer("node:22-bookworm");
50+
const output: string[] = [];
51+
52+
try {
53+
const setup = await execContainer(container, ["bash", "-c", mockCommands]);
54+
expect(setup.exitCode).toBe(0);
55+
if (options.beforeAuth) {
56+
const preparation = await execContainer(container, [
57+
"bash",
58+
"-c",
59+
options.beforeAuth,
60+
]);
61+
expect(preparation.exitCode).toBe(0);
62+
}
63+
for (const name of ["install_script", "post_install_script"]) {
64+
const script = findResourceInstance(state, "coder_script", name).script;
65+
const result = await execContainer(
66+
container,
67+
["bash", "-c", script],
68+
options.env?.flatMap((value) => ["--env", value]),
69+
);
70+
output.push(result.stdout, result.stderr);
71+
if (result.exitCode !== 0) {
72+
return { exitCode: result.exitCode, output: output.join("\n") };
73+
}
74+
}
75+
if (options.after) {
76+
const result = await execContainer(container, [
77+
"bash",
78+
"-c",
79+
options.after,
80+
]);
81+
output.push(result.stdout, result.stderr);
82+
return { exitCode: result.exitCode, output: output.join("\n") };
83+
}
84+
return { exitCode: 0, output: output.join("\n") };
85+
} finally {
86+
await removeContainer(container);
87+
}
88+
}
89+
90+
describe("mcp-servers authentication", () => {
91+
beforeAll(async () => {
92+
await runTerraformInit(import.meta.dir);
93+
});
94+
95+
it("renders the exact baseline install script for explicit mode none", async () => {
96+
const variables = { agent_id: "test-agent", clients, default: github };
97+
const [implicit, explicit] = await Promise.all([
98+
runTerraformApply(import.meta.dir, variables),
99+
runTerraformApply(import.meta.dir, {
100+
...variables,
101+
github_auth: JSON.stringify({ mode: "none" }),
102+
}),
103+
]);
104+
105+
expect(
106+
findResourceInstance(explicit, "coder_script", "install_script").script,
107+
).toBe(
108+
findResourceInstance(implicit, "coder_script", "install_script").script,
109+
);
110+
expect(
111+
explicit.resources.some((item) => item.name === "post_install_script"),
112+
).toBeFalse();
113+
});
114+
115+
it("configures token references without writing the token value", async () => {
116+
const state = await runTerraformApply(import.meta.dir, {
117+
agent_id: "test-agent",
118+
clients,
119+
default: github,
120+
github_auth: JSON.stringify({
121+
mode: "token-env",
122+
token_env_var: "WORKSPACE_GITHUB_TOKEN",
123+
}),
124+
});
125+
const result = await executePipeline(state, {
126+
env: ["WORKSPACE_GITHUB_TOKEN=super-secret-test-value"],
127+
after: `
128+
set -e
129+
node - <<'NODE'
130+
const fs = require("node:fs");
131+
const claude = JSON.parse(fs.readFileSync(process.env.HOME + "/.claude.json"));
132+
const cursor = JSON.parse(fs.readFileSync(process.env.HOME + "/.cursor/mcp.json"));
133+
if (claude.mcpServers.github.headers.Authorization !== "Bearer \${WORKSPACE_GITHUB_TOKEN}") process.exit(1);
134+
if (cursor.mcpServers.github.headers.Authorization !== "Bearer \${env:WORKSPACE_GITHUB_TOKEN}") process.exit(1);
135+
NODE
136+
grep -q -- '--bearer-token-env-var WORKSPACE_GITHUB_TOKEN' /tmp/codex-calls
137+
! grep -R 'super-secret-test-value' "$HOME/.claude.json" "$HOME/.cursor/mcp.json" "$HOME/.codex/config.toml" /tmp/codex-calls
138+
`,
139+
});
140+
141+
expect(result.exitCode).toBe(0);
142+
expect(result.output).not.toContain("super-secret-test-value");
143+
expect(result.output).toContain("no token value was written");
144+
});
145+
146+
it("fails explicitly when the token environment variable is missing", async () => {
147+
const state = await runTerraformApply(import.meta.dir, {
148+
agent_id: "test-agent",
149+
clients,
150+
default: github,
151+
github_auth: JSON.stringify({
152+
mode: "token-env",
153+
token_env_var: "WORKSPACE_GITHUB_TOKEN",
154+
}),
155+
});
156+
const result = await executePipeline(state);
157+
158+
expect(result.exitCode).not.toBe(0);
159+
expect(result.output).toContain(
160+
"requires the WORKSPACE_GITHUB_TOKEN environment variable",
161+
);
162+
expect(result.output).not.toContain("no token value was written");
163+
});
164+
165+
it("resolves external auth only through Claude's runtime helper", async () => {
166+
const state = await runTerraformApply(import.meta.dir, {
167+
agent_id: "test-agent",
168+
clients: JSON.stringify(["claude code"]),
169+
default: github,
170+
github_auth: JSON.stringify({
171+
mode: "external-auth",
172+
external_auth_id: "primary-github",
173+
}),
174+
});
175+
const result = await executePipeline(state, {
176+
after: `
177+
set -e
178+
helper="$HOME/.coder-modules/coder/mcp-servers/scripts/github-headers.sh"
179+
"$helper" > /tmp/github-headers.json
180+
node -e 'const h=require("/tmp/github-headers.json");if(h.Authorization!=="Bearer dynamic-test-token")process.exit(1)'
181+
rm /tmp/github-headers.json
182+
grep -q 'headersHelper' "$HOME/.claude.json"
183+
! grep -R 'dynamic-test-token' "$helper" "$HOME/.claude.json"
184+
`,
185+
});
186+
187+
expect(result.exitCode).toBe(0);
188+
expect(result.output).not.toContain("dynamic-test-token");
189+
expect(result.output).toContain("primary-github");
190+
});
191+
192+
it("reports the external auth action when the provider is not connected", async () => {
193+
const state = await runTerraformApply(import.meta.dir, {
194+
agent_id: "test-agent",
195+
clients: JSON.stringify(["claude code"]),
196+
default: github,
197+
github_auth: JSON.stringify({ mode: "external-auth" }),
198+
});
199+
const result = await executePipeline(state, {
200+
beforeAuth: `cat > /usr/local/bin/coder <<'EOF'
201+
#!/bin/sh
202+
if [ "$1" = "external-auth" ] && [ "$2" = "access-token" ]; then
203+
printf '%s' 'https://coder.example/external-auth/github'
204+
exit 1
205+
fi
206+
exit 0
207+
EOF
208+
chmod +x /usr/local/bin/coder`,
209+
after: `"$HOME/.coder-modules/coder/mcp-servers/scripts/github-headers.sh"`,
210+
});
211+
expect(result.exitCode).not.toBe(0);
212+
expect(result.output).toContain("Authenticate at: https://coder.example");
213+
expect(result.output).not.toContain("dynamic-test-token");
214+
});
215+
216+
it("pins native OAuth to the official image and loopback callback", async () => {
217+
const state = await runTerraformApply(import.meta.dir, {
218+
agent_id: "test-agent",
219+
clients,
220+
default: github,
221+
github_auth: JSON.stringify({ mode: "native-oauth" }),
222+
});
223+
const result = await executePipeline(state);
224+
225+
expect(result.exitCode).toBe(0);
226+
expect(result.output).toContain("-p 127.0.0.1:8085:8085");
227+
expect(result.output).toContain(
228+
"github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699",
229+
);
230+
expect(result.output).not.toContain("PERSONAL_ACCESS_TOKEN");
231+
});
232+
233+
it("skips user authentication while the workspace owner is prebuilds", async () => {
234+
const state = await runTerraformApply(
235+
import.meta.dir,
236+
{
237+
agent_id: "test-agent",
238+
clients: JSON.stringify(["claude code"]),
239+
default: github,
240+
github_auth: JSON.stringify({ mode: "external-auth" }),
241+
},
242+
{ CODER_WORKSPACE_OWNER: "prebuilds" },
243+
);
244+
const result = await executePipeline(state);
245+
246+
expect(result.exitCode).toBe(0);
247+
expect(result.output).toContain("after claim");
248+
expect(result.output).not.toContain("dynamic-test-token");
249+
});
250+
});

0 commit comments

Comments
 (0)