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
40 changes: 40 additions & 0 deletions .github/workflows/linear-mcp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Linear MCP tests
on:
pull_request:
paths:
- "modules/linear-mcp/**"
- ".github/workflows/linear-mcp.yaml"
push:
branches: [main]
paths:
- "modules/linear-mcp/**"
- ".github/workflows/linear-mcp.yaml"

jobs:
adapter:
runs-on: ubuntu-latest
defaults:
run:
working-directory: modules/linear-mcp
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- run: npm install --global pnpm@11.19.0 --ignore-scripts --no-audit --no-fund
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: node --test tests/adapter.test.mjs

terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: modules/linear-mcp
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
with:
terraform_version: "1.13.5"
- run: terraform fmt -check
- run: terraform init -backend=false
- run: terraform test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.terraform/
.terraform.lock.hcl
__pycache__/

node_modules/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,19 @@ module "git_credential_coder" {
agent_id = coder_agent.main.id
}
```

## `linear-mcp`

[`modules/linear-mcp`](modules/linear-mcp) installs a Linear read-only MCP
launcher backed by Coder external authentication. Unlinked accounts keep a
healthy MCP connection with an empty tool list; tools become available after
linking the account. The module installs the adapter and exposes its launcher
command; the calling template registers it in its coding clients.

```hcl
module "linear_mcp" {
source = "./modules/linear-mcp"
agent_id = coder_agent.main.id
external_auth_id = "linear"
}
```
102 changes: 102 additions & 0 deletions modules/linear-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Linear read-only MCP

Installs a stdio MCP adapter that connects to [Linear's official read-only
MCP endpoint](https://linear.app/docs/mcp), using the workspace owner's Coder
external-auth token. It exposes Linear's read tools to Claude Code, Codex, or
other MCP clients without a second OAuth login.

## Usage

Configure a Linear OAuth external-auth provider on the Coder deployment first.
The module does not provision OAuth applications, client secrets, or account
links. Linear API authorization uses OAuth 2.0; request the `read` scope.

```hcl
data "coder_external_auth" "linear" {
id = "linear"
optional = true
}

module "linear_mcp" {
source = "./modules/linear-mcp"
agent_id = coder_agent.main.id
external_auth_id = "linear"
}
```

The module installs `coder-linear-mcp` in `$HOME/.local/bin` and adds that
location to the agent PATH. Register `module.linear_mcp.launcher_command` in
your clients' MCP configuration. For example, Claude Code accepts:

```json
{
"mcpServers": {
"linear": { "command": "coder-linear-mcp" }
}
}
```

Codex accepts this TOML:

```toml
[mcp_servers.linear]
command = "coder-linear-mcp"
```

Client registration remains the calling template's responsibility. Registration
applies to the agent on which the module runs; clients in another container need
access to the executable, dependencies, and Coder credential context.

## Optional authentication

Without a linked account, MCP initialization and ping succeed, `tools/list`
returns an empty list, and nothing is sent to Linear. The server stays running
without printing an authentication error. A stale tool call returns a normal
MCP tool result with `isError: true` explaining how to link Linear.

The adapter checks authentication every 30 seconds. After linking Linear in
Coder, it sends `notifications/tools/list_changed` when tools become available.
Clients that honor that notification update automatically; clients that cache
tool lists may require a reconnect. Unlinking hides tools again. Rejected
upstream credentials also hide tools while keeping the local MCP healthy.
Network outages return a sanitized MCP error and can recover on later requests.

## Installation and credentials

The agent image must provide **standalone pnpm** on PATH (or at
`/usr/local/share/pnpm/bin/pnpm`) with Node environment management available.
The startup script provisions user-local Node 24 and installs the official
MCP SDK with the checked-in frozen dependency lockfile and lifecycle scripts
disabled. Initial installation needs access to the public Node/npm registries;
later starts reuse dependencies when the lockfile is unchanged. It runs
independently of optional Linear account linking.

Runtime requires Coder's workspace credential context and CLI, available through
`GIT_ASKPASS` or PATH. Each upstream HTTP request retrieves a current token with
`coder external-auth access-token <external_auth_id>`; Coder handles refresh.
The module stores no access or refresh tokens in Terraform, client config,
files, or command arguments. It uses a fixed read-only endpoint, rejects
redirects, and never starts a separate browser OAuth flow. The SDK dependency
lockfile contains only public package locations and integrity hashes.

| Input | Default | Description |
| --- | --- | --- |
| `agent_id` | Required | Coder agent receiving the installer and PATH entry |
| `external_auth_id` | `linear` | Linear external-auth provider ID in Coder |

Outputs: `launcher_command` is the PATH command; `launcher_path` is its
`$HOME`-relative location (expand HOME before using it as an absolute command).

## Validation

```sh
cd modules/linear-mcp
pnpm install --frozen-lockfile --ignore-scripts
node --test tests/adapter.test.mjs
terraform init -backend=false
terraform test
```

Node tests cover skipped auth, linking/unlinking on the same connection,
per-request token acquisition, rejected credentials, sanitized upstream errors,
and the actual stdio subprocess. Terraform tests use a mock Coder provider.
29 changes: 29 additions & 0 deletions modules/linear-mcp/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}

resource "coder_env" "linear_mcp_path" {
agent_id = var.agent_id
name = "PATH"
value = "$HOME/.local/bin:$PATH"
merge_strategy = "prepend"
}

resource "coder_script" "linear_mcp" {
agent_id = var.agent_id
display_name = "Install Linear read-only MCP"
run_on_start = true
start_blocks_login = true
timeout = 300
script = templatefile("${path.module}/scripts/install.sh.tpl", {
external_auth_id = var.external_auth_id
adapter_js = file("${path.module}/scripts/linear-mcp.mjs")
package_json = file("${path.module}/package.json")
pnpm_lock = file("${path.module}/pnpm-lock.yaml")
dependency_hash = sha256(file("${path.module}/pnpm-lock.yaml"))
})
}
41 changes: 41 additions & 0 deletions modules/linear-mcp/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
mock_provider "coder" {}

run "defaults" {
command = plan
variables {
agent_id = "test-agent"
}
assert {
condition = output.launcher_command == "coder-linear-mcp"
error_message = "Clients must receive a stable launcher command."
}
assert {
condition = strcontains(coder_script.linear_mcp.script, "--frozen-lockfile --ignore-scripts")
error_message = "The installer must use the frozen dependency graph without lifecycle scripts."
}
assert {
condition = strcontains(coder_script.linear_mcp.script, "https://mcp.linear.app/mcp/readonly")
error_message = "Only the official read-only endpoint is supported."
}
}

run "custom_provider" {
command = plan
variables {
agent_id = "test-agent"
external_auth_id = "linear-secondary"
}
assert {
condition = strcontains(coder_script.linear_mcp.script, "'linear-secondary'")
error_message = "The launcher must pass the configured external-auth ID."
}
}

run "reject_shell_characters" {
command = plan
variables {
agent_id = "test-agent"
external_auth_id = "linear;false"
}
expect_failures = [var.external_auth_id]
}
9 changes: 9 additions & 0 deletions modules/linear-mcp/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
output "launcher_command" {
description = "MCP stdio command on the agent PATH, usable in Claude Code, Codex, or other clients."
value = "coder-linear-mcp"
}

output "launcher_path" {
description = "User-scoped launcher location. Expand HOME before using this as an absolute client command."
value = "$HOME/.local/bin/coder-linear-mcp"
}
9 changes: 9 additions & 0 deletions modules/linear-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "coder-linear-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "1.32.1"
}
}
Loading
Loading