From 514acab2e14915c87bf13d33bbaa73dc93ac83ba Mon Sep 17 00:00:00 2001 From: Tom Petr Date: Wed, 14 Oct 2026 00:48:14 +0000 Subject: [PATCH 1/2] feat(linear-mcp): support read-only and read-write modes --- README.md | 4 ++- modules/linear-mcp/README.md | 26 +++++++++++++----- modules/linear-mcp/main.tf | 3 ++- modules/linear-mcp/main.tftest.hcl | 32 +++++++++++++++++++++++ modules/linear-mcp/outputs.tf | 5 ++++ modules/linear-mcp/scripts/install.sh.tpl | 4 +-- modules/linear-mcp/scripts/linear-mcp.mjs | 13 +++++---- modules/linear-mcp/tests/adapter.test.mjs | 25 +++++++++++++++--- modules/linear-mcp/variables.tf | 13 ++++++++- 9 files changed, 105 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 49029da..e4135bf 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,13 @@ module "git_credential_coder" { ## `linear-mcp` -[`modules/linear-mcp`](modules/linear-mcp) installs a Linear read-only MCP +[`modules/linear-mcp`](modules/linear-mcp) installs a Linear 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. +`access_mode` defaults to `read-only`; choose `read-write` to expose write tools +with a write-capable OAuth grant. ```hcl module "linear_mcp" { diff --git a/modules/linear-mcp/README.md b/modules/linear-mcp/README.md index d0f981b..03a5f90 100644 --- a/modules/linear-mcp/README.md +++ b/modules/linear-mcp/README.md @@ -1,15 +1,17 @@ -# Linear read-only MCP +# Linear 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 +Installs a stdio MCP adapter that connects to [Linear's official +MCP endpoints](https://linear.app/docs/mcp), using the workspace owner's Coder +external-auth token. It exposes Linear's 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. +links. Linear API authorization uses OAuth 2.0. Request `read` for read-only use, +or `read,write` to enable writes. Existing grants need reauthorization when +adding write scope. ```hcl data "coder_external_auth" "linear" { @@ -21,6 +23,7 @@ module "linear_mcp" { source = "./modules/linear-mcp" agent_id = coder_agent.main.id external_auth_id = "linear" + access_mode = "read-only" # Set read-write to enable write tools. } ``` @@ -47,6 +50,14 @@ 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. +## Access mode + +`read-only` (the default) selects `https://mcp.linear.app/mcp/readonly`, which +exposes only read tools even when the token permits writes. `read-write` +selects `https://mcp.linear.app/mcp`; writes also require a write-capable OAuth +grant. This setting controls MCP access, not the Coder token's underlying +permissions. Restart MCP clients after changing mode so they rediscover tools. + ## Optional authentication Without a linked account, MCP initialization and ping succeed, `tools/list` @@ -75,7 +86,7 @@ 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 `; 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 +files, or command arguments. It uses only the selected official endpoint, rejects redirects, and never starts a separate browser OAuth flow. The SDK dependency lockfile contains only public package locations and integrity hashes. @@ -83,8 +94,9 @@ lockfile contains only public package locations and integrity hashes. | --- | --- | --- | | `agent_id` | Required | Coder agent receiving the installer and PATH entry | | `external_auth_id` | `linear` | Linear external-auth provider ID in Coder | +| `access_mode` | `read-only` | `read-only` or `read-write` MCP endpoint | -Outputs: `launcher_command` is the PATH command; `launcher_path` is its +Outputs: `access_mode` is the selected mode; `launcher_command` is the PATH command; `launcher_path` is its `$HOME`-relative location (expand HOME before using it as an absolute command). ## Validation diff --git a/modules/linear-mcp/main.tf b/modules/linear-mcp/main.tf index 0ad1b08..8fc0d2b 100644 --- a/modules/linear-mcp/main.tf +++ b/modules/linear-mcp/main.tf @@ -15,12 +15,13 @@ resource "coder_env" "linear_mcp_path" { resource "coder_script" "linear_mcp" { agent_id = var.agent_id - display_name = "Install Linear read-only MCP" + display_name = "Install Linear 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 + access_mode = var.access_mode adapter_js = file("${path.module}/scripts/linear-mcp.mjs") package_json = file("${path.module}/package.json") pnpm_lock = file("${path.module}/pnpm-lock.yaml") diff --git a/modules/linear-mcp/main.tftest.hcl b/modules/linear-mcp/main.tftest.hcl index 939efa5..0289500 100644 --- a/modules/linear-mcp/main.tftest.hcl +++ b/modules/linear-mcp/main.tftest.hcl @@ -19,6 +19,17 @@ run "defaults" { } } +run "default_read_only" { + command = plan + variables { + agent_id = "test-agent" + } + assert { + condition = strcontains(coder_script.linear_mcp.script, "'linear' 'read-only'") + error_message = "Existing consumers must retain read-only access by default." + } +} + run "custom_provider" { command = plan variables { @@ -39,3 +50,24 @@ run "reject_shell_characters" { } expect_failures = [var.external_auth_id] } + +run "read_write_mode" { + command = plan + variables { + agent_id = "test-agent" + access_mode = "read-write" + } + assert { + condition = strcontains(coder_script.linear_mcp.script, "'linear' 'read-write'") + error_message = "The launcher must pass the selected access mode to the adapter." + } +} + +run "reject_invalid_mode" { + command = plan + variables { + agent_id = "test-agent" + access_mode = "custom-url" + } + expect_failures = [var.access_mode] +} diff --git a/modules/linear-mcp/outputs.tf b/modules/linear-mcp/outputs.tf index cdef14f..5312f33 100644 --- a/modules/linear-mcp/outputs.tf +++ b/modules/linear-mcp/outputs.tf @@ -7,3 +7,8 @@ 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" } + +output "access_mode" { + description = "Selected MCP access mode. This does not change OAuth token permissions." + value = var.access_mode +} diff --git a/modules/linear-mcp/scripts/install.sh.tpl b/modules/linear-mcp/scripts/install.sh.tpl index fe71e1e..1ce9cd6 100644 --- a/modules/linear-mcp/scripts/install.sh.tpl +++ b/modules/linear-mcp/scripts/install.sh.tpl @@ -28,7 +28,7 @@ ADAPTER cat > "$HOME/.local/bin/coder-linear-mcp" <<'LAUNCHER' #!/bin/sh exec "$HOME/.local/share/coder-linear-mcp/pnpm/bin/node" \ - "$HOME/.local/share/coder-linear-mcp/app/linear-mcp.mjs" '${external_auth_id}' + "$HOME/.local/share/coder-linear-mcp/app/linear-mcp.mjs" '${external_auth_id}' '${access_mode}' LAUNCHER chmod 0755 "$HOME/.local/bin/coder-linear-mcp" -echo 'Linear read-only MCP installed; unlinked accounts expose no tools.' +echo 'Linear MCP installed (${access_mode}); unlinked accounts expose no tools.' diff --git a/modules/linear-mcp/scripts/linear-mcp.mjs b/modules/linear-mcp/scripts/linear-mcp.mjs index ee6c8fc..9aadb07 100644 --- a/modules/linear-mcp/scripts/linear-mcp.mjs +++ b/modules/linear-mcp/scripts/linear-mcp.mjs @@ -11,6 +11,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } fr const run = promisify(execFile); export const LINEAR_URL = 'https://mcp.linear.app/mcp/readonly'; +const endpoints = new Map([['read-only', LINEAR_URL], ['read-write', 'https://mcp.linear.app/mcp']]); export class AuthUnavailable extends Error {} export async function coderToken(providerId) { @@ -26,7 +27,9 @@ export async function coderToken(providerId) { } } -export function createAdapter({ providerId = 'linear', getToken = () => coderToken(providerId), fetchImpl = fetch } = {}) { +export function createAdapter({ providerId = 'linear', accessMode = 'read-only', getToken = () => coderToken(providerId), fetchImpl = fetch } = {}) { + const upstreamUrl = endpoints.get(accessMode); + if (!upstreamUrl) throw new Error('Invalid Linear access mode'); const server = new Server({ name: 'coder-linear-mcp', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } }, }); @@ -49,8 +52,8 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok }; const authenticatedFetch = async (url, options) => { // No configurable upstream and no redirects: credentials go only to the - // official read-only endpoint. No OAuth provider/browser flow is used. - if (String(url) !== LINEAR_URL) throw new Error('Unexpected MCP endpoint'); + // selected official endpoint. No OAuth provider/browser flow is used. + if (String(url) !== upstreamUrl) throw new Error('Unexpected MCP endpoint'); const token = await getToken(); const headers = new Headers(options?.headers); headers.set('authorization', `Bearer ${token}`); @@ -70,7 +73,7 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok if (!connecting) connecting = (async () => { const candidate = new Client({ name: 'coder-linear-mcp', version: '1.0.0' }); try { - await candidate.connect(new StreamableHTTPClientTransport(new URL(LINEAR_URL), { + await candidate.connect(new StreamableHTTPClientTransport(new URL(upstreamUrl), { fetch: authenticatedFetch, reconnectionOptions: { maxRetries: 0 }, })); @@ -133,7 +136,7 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok async function main() { const providerId = process.argv[2] || 'linear'; if (!/^[a-z0-9][a-z0-9-]*$/.test(providerId)) throw new Error('Invalid provider ID'); - const adapter = createAdapter({ providerId }); + const adapter = createAdapter({ providerId, accessMode: process.argv[3] || 'read-only' }); await adapter.server.connect(new StdioServerTransport()); process.stdin.once('end', () => { adapter.close(); }); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { diff --git a/modules/linear-mcp/tests/adapter.test.mjs b/modules/linear-mcp/tests/adapter.test.mjs index e591ff8..072381b 100644 --- a/modules/linear-mcp/tests/adapter.test.mjs +++ b/modules/linear-mcp/tests/adapter.test.mjs @@ -9,10 +9,11 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; import { AuthUnavailable, createAdapter, LINEAR_URL } from '../scripts/linear-mcp.mjs'; -function fakeLinear() { +function fakeLinear(accessMode = 'read-only') { + const endpoint = accessMode === 'read-only' ? LINEAR_URL : 'https://mcp.linear.app/mcp'; const requests = []; const fetchImpl = async (url, options) => { - assert.equal(String(url), LINEAR_URL); + assert.equal(String(url), endpoint); assert.equal(options.redirect, 'error'); requests.push({ method: options.method, token: options.headers.get('authorization') }); if (options.method !== 'POST') return new Response(null, { status: 405 }); @@ -24,7 +25,10 @@ function fakeLinear() { capabilities: { tools: {} }, serverInfo: { name: 'fake-linear', version: '1' }, }; else if (message.method === 'tools/list') result = { - tools: [{ name: 'list_issues', description: 'Read issues', inputSchema: { type: 'object' } }], + tools: [ + { name: 'list_issues', description: 'Read issues', inputSchema: { type: 'object' } }, + ...(accessMode === 'read-write' ? [{ name: 'create_issue', inputSchema: { type: 'object' } }] : []), + ], }; else result = { content: [{ type: 'text', text: 'Example issue' }] }; return Response.json({ jsonrpc: '2.0', id: message.id, result }); @@ -135,3 +139,18 @@ test('actual stdio launcher remains healthy when the Coder subprocess is not aut await client.close(); assert.equal(stderr, ''); }); + +for (const mode of ['read-only', 'read-write']) { + test(`${mode} selects its fixed official endpoint and corresponding tools`, async t => { + const remote = fakeLinear(mode); + const { client } = await connect(t, { + accessMode: mode, getToken: async () => 'fake-token', fetchImpl: remote.fetchImpl, + }); + const names = (await client.listTools()).tools.map(tool => tool.name); + assert.deepEqual(names, mode === 'read-only' ? ['list_issues'] : ['list_issues', 'create_issue']); + }); +} + +test('invalid access modes cannot select an arbitrary endpoint', () => { + assert.throws(() => createAdapter({ accessMode: 'https://example.invalid' }), /Invalid Linear access mode/); +}); diff --git a/modules/linear-mcp/variables.tf b/modules/linear-mcp/variables.tf index 3b33e31..8285bdd 100644 --- a/modules/linear-mcp/variables.tf +++ b/modules/linear-mcp/variables.tf @@ -1,5 +1,5 @@ variable "agent_id" { - description = "Coder agent on which to install the Linear read-only MCP launcher." + description = "Coder agent on which to install the Linear MCP launcher." type = string } @@ -13,3 +13,14 @@ variable "external_auth_id" { error_message = "external_auth_id must contain lowercase letters, digits, and hyphens." } } + +variable "access_mode" { + description = "Linear MCP tool access: read-only or read-write. OAuth scopes must also permit writes." + type = string + default = "read-only" + + validation { + condition = contains(["read-only", "read-write"], var.access_mode) + error_message = "access_mode must be read-only or read-write." + } +} From 89f8301ffc2d186bbe745ea6787d7741ef5028f1 Mon Sep 17 00:00:00 2001 From: Tom Petr Date: Wed, 14 Oct 2026 00:52:09 +0000 Subject: [PATCH 2/2] fix(linear-mcp): show Coder linking URL when unauthenticated --- modules/linear-mcp/README.md | 8 +++- modules/linear-mcp/scripts/linear-mcp.mjs | 47 ++++++++++++++++---- modules/linear-mcp/tests/adapter.test.mjs | 52 ++++++++++++++++++++++- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/modules/linear-mcp/README.md b/modules/linear-mcp/README.md index 03a5f90..d9212ff 100644 --- a/modules/linear-mcp/README.md +++ b/modules/linear-mcp/README.md @@ -62,8 +62,12 @@ permissions. Restart MCP clients after changing mode so they rediscover tools. 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. +without failing authentication. When Coder supplies an account-linking URL, +the adapter prints that URL once to stderr, and a stale tool call returns a +normal MCP tool result with `isError: true` containing the URL to open. Only +the Coder linking page is retained; query parameters and raw subprocess errors +are never printed. If the credential context is unavailable and Coder cannot +supply a URL, the reply gives the CLI command to retrieve it. The adapter checks authentication every 30 seconds. After linking Linear in Coder, it sends `notifications/tools/list_changed` when tools become available. diff --git a/modules/linear-mcp/scripts/linear-mcp.mjs b/modules/linear-mcp/scripts/linear-mcp.mjs index 9aadb07..7a301bb 100644 --- a/modules/linear-mcp/scripts/linear-mcp.mjs +++ b/modules/linear-mcp/scripts/linear-mcp.mjs @@ -12,7 +12,25 @@ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } fr const run = promisify(execFile); export const LINEAR_URL = 'https://mcp.linear.app/mcp/readonly'; const endpoints = new Map([['read-only', LINEAR_URL], ['read-write', 'https://mcp.linear.app/mcp']]); -export class AuthUnavailable extends Error {} +export class AuthUnavailable extends Error { + constructor(authUrl) { + super('Linear authentication unavailable'); + this.authUrl = authUrl; + } +} + +export function authenticationUrl(stdout, providerId) { + try { + const url = new URL(stdout.trim()); + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || + url.pathname !== `/external-auth/${providerId}`) return undefined; + // Only return the Coder linking page, never query parameters or raw + // subprocess output that could contain credentials or diagnostics. + url.search = ''; + url.hash = ''; + return url.href; + } catch { return undefined; } +} export async function coderToken(providerId) { try { @@ -21,13 +39,14 @@ export async function coderToken(providerId) { const token = stdout.trim(); if (!token || /\s/.test(token)) throw new AuthUnavailable(); return token; - } catch { - // Subprocess errors may contain stdout/stderr with sensitive credentials. - throw new AuthUnavailable(); + } catch (error) { + // Coder prints the linking URL to stdout and exits 1 when unlinked. + // Parse only that exact URL shape; never relay raw stdout/stderr. + throw new AuthUnavailable(authenticationUrl(error.stdout || '', providerId)); } } -export function createAdapter({ providerId = 'linear', accessMode = 'read-only', getToken = () => coderToken(providerId), fetchImpl = fetch } = {}) { +export function createAdapter({ providerId = 'linear', accessMode = 'read-only', getToken = () => coderToken(providerId), fetchImpl = fetch, log = message => console.error(message) } = {}) { const upstreamUrl = endpoints.get(accessMode); if (!upstreamUrl) throw new Error('Invalid Linear access mode'); const server = new Server({ name: 'coder-linear-mcp', version: '1.0.0' }, { @@ -39,6 +58,7 @@ export function createAdapter({ providerId = 'linear', accessMode = 'read-only', let closed = false; let poll; let shutdownPromise; + let reportedAuthUrl; const notify = () => { if (initialized && !closed) server.sendToolListChanged().catch(() => {}); }; @@ -88,9 +108,18 @@ export function createAdapter({ providerId = 'linear', accessMode = 'read-only', })().finally(() => { connecting = undefined; }); return connecting; }; + const reportAuth = error => { + if (error instanceof AuthUnavailable && error.authUrl && error.authUrl !== reportedAuthUrl) { + reportedAuthUrl = error.authUrl; + log(`Linear MCP: link your account at ${error.authUrl}`); + } + }; const unavailable = async error => { await disconnect(); - if (error instanceof AuthUnavailable) return true; + if (error instanceof AuthUnavailable) { + reportAuth(error); + return true; + } // Avoid returning SDK errors or upstream response bodies to the client. throw new McpError(ErrorCode.InternalError, 'Linear MCP is temporarily unavailable. Try again later.'); }; @@ -107,14 +136,16 @@ export function createAdapter({ providerId = 'linear', accessMode = 'read-only', } catch (error) { if (await unavailable(error)) return { isError: true, - content: [{ type: 'text', text: 'Link Linear in Coder to use Linear tools. Authentication is optional for this workspace.' }], + content: [{ type: 'text', text: error.authUrl + ? `Open ${error.authUrl} to link Linear in Coder, then retry. Authentication is optional for this workspace.` + : `Link Linear in Coder to use Linear tools. Run \`coder external-auth access-token ${providerId}\` in the workspace to obtain the authentication URL. Authentication is optional for this workspace.` }], }; } }); // Clients can discover newly linked accounts without restarting. Also // detect account removal while idle; revoked tokens are caught by fetch. const checkAuth = async () => { - try { await ensureClient(); } catch { await disconnect(); } + try { await ensureClient(); } catch (error) { reportAuth(error); await disconnect(); } }; server.oninitialized = () => { initialized = true; diff --git a/modules/linear-mcp/tests/adapter.test.mjs b/modules/linear-mcp/tests/adapter.test.mjs index 072381b..9efe3d6 100644 --- a/modules/linear-mcp/tests/adapter.test.mjs +++ b/modules/linear-mcp/tests/adapter.test.mjs @@ -7,7 +7,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; -import { AuthUnavailable, createAdapter, LINEAR_URL } from '../scripts/linear-mcp.mjs'; +import { AuthUnavailable, authenticationUrl, createAdapter, LINEAR_URL } from '../scripts/linear-mcp.mjs'; function fakeLinear(accessMode = 'read-only') { const endpoint = accessMode === 'read-only' ? LINEAR_URL : 'https://mcp.linear.app/mcp'; @@ -154,3 +154,53 @@ for (const mode of ['read-only', 'read-write']) { test('invalid access modes cannot select an arbitrary endpoint', () => { assert.throws(() => createAdapter({ accessMode: 'https://example.invalid' }), /Invalid Linear access mode/); }); + +test('unlinked tool replies include Coder linking URL and diagnostics are emitted once', async t => { + const url = 'https://coder.example.test/external-auth/linear'; + const messages = []; + const { adapter, client } = await connect(t, { + getToken: async () => { throw new AuthUnavailable(url); }, + log: message => messages.push(message), + fetchImpl: async () => assert.fail('must not contact Linear'), + }); + assert.deepEqual((await client.listTools()).tools, []); + const result = await client.callTool({ name: 'list_issues', arguments: {} }); + assert.equal(result.isError, true); + assert.ok(result.content[0].text.includes(url)); + await adapter.checkAuth(); + await client.ping(); + assert.deepEqual(messages, [`Linear MCP: link your account at ${url}`]); +}); + +test('authentication URL parsing strips queries and rejects arbitrary subprocess output', () => { + assert.equal(authenticationUrl('https://coder.example.test/external-auth/linear?secret=redacted#fragment', 'linear'), + 'https://coder.example.test/external-auth/linear'); + for (const output of ['sensitive-token', 'https://example.test/not-a-link', + 'https://user:password@coder.example.test/external-auth/linear', + 'Error: https://coder.example.test/external-auth/linear']) { + assert.equal(authenticationUrl(output, 'linear'), undefined); + } +}); + +test('real Coder CLI exit-one URL reaches the stdio tool result without leaking diagnostics', async t => { + const directory = await mkdtemp(join(tmpdir(), 'coder-linear-url-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const fakeCoder = join(directory, 'coder'); + await writeFile(fakeCoder, '#!/bin/sh\necho https://coder.example.test/external-auth/custom-linear?secret=redacted\necho sensitive-error >&2\nexit 1\n', { mode: 0o700 }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [new URL('../scripts/linear-mcp.mjs', import.meta.url).pathname, 'custom-linear'], + env: { GIT_ASKPASS: fakeCoder }, stderr: 'pipe', + }); + let stderr = ''; + transport.stderr.on('data', chunk => { stderr += chunk; }); + const client = new Client({ name: 'url-test', version: '1' }); + t.after(() => client.close()); + await client.connect(transport); + const result = await client.callTool({ name: 'list_issues', arguments: {} }); + assert.ok(result.content[0].text.includes('https://coder.example.test/external-auth/custom-linear')); + assert.ok(!result.content[0].text.includes('secret=')); + await client.close(); + assert.ok(stderr.includes('https://coder.example.test/external-auth/custom-linear')); + assert.ok(!stderr.includes('secret=') && !stderr.includes('sensitive-error')); +});