Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .icons/mcp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 108 additions & 0 deletions registry/coder/modules/mcp-servers/README.md
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.
Comment thread
DevelopmentCats marked this conversation as resolved.

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.
250 changes: 250 additions & 0 deletions registry/coder/modules/mcp-servers/auth.test.ts
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");
});
});
Loading