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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
34 changes: 25 additions & 9 deletions modules/linear-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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" {
Expand All @@ -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.
}
```

Expand All @@ -47,12 +50,24 @@ 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`
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.
Expand All @@ -75,16 +90,17 @@ 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
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.

| 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 |
| `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
Expand Down
3 changes: 2 additions & 1 deletion modules/linear-mcp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 32 additions & 0 deletions modules/linear-mcp/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
}
5 changes: 5 additions & 0 deletions modules/linear-mcp/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions modules/linear-mcp/scripts/install.sh.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
58 changes: 46 additions & 12 deletions modules/linear-mcp/scripts/linear-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,26 @@ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } fr

const run = promisify(execFile);
export const LINEAR_URL = 'https://mcp.linear.app/mcp/readonly';
export class AuthUnavailable extends Error {}
const endpoints = new Map([['read-only', LINEAR_URL], ['read-write', 'https://mcp.linear.app/mcp']]);
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 {
Expand All @@ -20,13 +39,16 @@ 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', 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' }, {
capabilities: { tools: { listChanged: true } },
});
Expand All @@ -36,6 +58,7 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok
let closed = false;
let poll;
let shutdownPromise;
let reportedAuthUrl;
const notify = () => {
if (initialized && !closed) server.sendToolListChanged().catch(() => {});
};
Expand All @@ -49,8 +72,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}`);
Expand All @@ -70,7 +93,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 },
}));
Expand All @@ -85,9 +108,18 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok
})().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.');
};
Expand All @@ -104,14 +136,16 @@ export function createAdapter({ providerId = 'linear', getToken = () => coderTok
} 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;
Expand All @@ -133,7 +167,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, () => {
Expand Down
77 changes: 73 additions & 4 deletions modules/linear-mcp/tests/adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ 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() {
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 });
Expand All @@ -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 });
Expand Down Expand Up @@ -135,3 +139,68 @@ 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/);
});

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'));
});
Loading
Loading