Skip to content
Open
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: 4 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ npm install
npm run dev -- --token YOUR_LINEAR_API_TOKEN
```

### Interactive OAuth login

The `auth` subcommands (`mcp-linear auth login|status|logout`) live in `src/auth/`. `login` runs a PKCE authorization-code flow against `https://linear.app/oauth/authorize` with a loopback callback server (default port 8734) and stores tokens in `$XDG_CONFIG_HOME/mcp-linear/credentials.json`. Set `MCP_LINEAR_CONFIG_DIR` to point the credential store somewhere else — the tests use this to write only inside a temp directory. The server falls back to the store when no explicit `--token` flag or `LINEAR_API_TOKEN`/`LINEAR_API_KEY` variable is provided and refreshes expired access tokens automatically, persisting Linear's rotated refresh tokens atomically (temp file plus rename). Never log token or secret values; errors must stay sanitized (HTTP status only).

### Validation

Use the following checks before merging or publishing:
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ Once connected, you can use prompts like:

## Installation

### Getting your Linear API token
### Authentication

#### Personal API key (default)

1. Log in to your Linear account at [linear.app](https://linear.app)
2. Click on your organization avatar (top-left corner)
Expand All @@ -63,6 +65,29 @@ Once connected, you can use prompts like:
6. Give your key a name (e.g., `MCP Linear Integration`)
7. Copy the generated API token and store it securely — you won't be able to see it again

Pass the key with `--token`, or set `LINEAR_API_TOKEN` (or `LINEAR_API_KEY`) in the environment.

#### Interactive OAuth login (no pasted keys)

Instead of pasting a token, you can sign in once and let the server manage its own credential:

```bash
mcp-linear auth login --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
```

The client id and secret can also come from `LINEAR_OAUTH_CLIENT_ID` / `LINEAR_OAUTH_CLIENT_SECRET`. They belong to an OAuth application you create at **Settings → API → Applications** with the redirect URI `http://localhost:8734/callback` (override the port with `--redirect-port`, which must match a registered redirect URI). Request different scopes with `--scopes read,write,issues:create`.

`auth login` starts a loopback server on 127.0.0.1, opens your browser to Linear's consent page (PKCE S256 plus a random `state`), exchanges the authorization code, and saves the credentials to `~/.config/mcp-linear/credentials.json` (`$XDG_CONFIG_HOME` and `MCP_LINEAR_CONFIG_DIR` are honored). The file and its directory are created with owner-only permissions (0600/0700), and token values are never printed or logged.

When the server starts without an explicit `--token` flag or `LINEAR_API_TOKEN`/`LINEAR_API_KEY` variable, it uses the stored credentials automatically. Explicit CLI and environment credentials always take precedence over the store. Linear access tokens expire and refresh tokens rotate; the server refreshes the access token automatically (at startup and mid-session) and atomically persists each rotated refresh token.

```bash
mcp-linear auth status # shows scopes, expiry, and a masked token suffix
mcp-linear auth logout # best-effort revocation, then deletes the stored file
```

`auth logout` still removes the local credentials even when the revocation request fails (for example, offline).

### Installing via [add-mcp](https://github.com/neondatabase/add-mcp) (Recommended)

`add-mcp` installs the server into Claude Code, Cursor, Codex, VS Code, Claude Desktop, and many other MCP-aware agents with a single command:
Expand Down Expand Up @@ -132,6 +157,13 @@ export LINEAR_API_TOKEN=YOUR_LINEAR_API_TOKEN
mcp-linear
```

Or sign in interactively once and run without any token at all (see [Interactive OAuth login](#interactive-oauth-login-no-pasted-keys)):

```bash
mcp-linear auth login --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
mcp-linear
```

## Validation

The default validation path is:
Expand Down
95 changes: 95 additions & 0 deletions src/__tests__/auth-callback-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { startOAuthCallbackServer } from '../auth/callback-server.js';

describe('OAuth loopback callback server', () => {
it('resolves with the authorization code when state matches', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 5_000,
});

const response = await fetch(
`http://127.0.0.1:${pending.port}/callback?code=auth-code-1&state=expected-state`,
);
const html = await response.text();

expect(response.status).toBe(200);
expect(html).toContain('close this tab');
await expect(pending.callback).resolves.toEqual({ code: 'auth-code-1' });
});

it('rejects and returns HTTP 400 on a state mismatch', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 5_000,
});
const rejection = expect(pending.callback).rejects.toThrow(/state/i);

const response = await fetch(
`http://127.0.0.1:${pending.port}/callback?code=auth-code-1&state=attacker-state`,
);

expect(response.status).toBe(400);
await rejection;
});

it('rejects when the provider redirects back with an error', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 5_000,
});
const rejection = expect(pending.callback).rejects.toThrow(/access_denied/);

const response = await fetch(
`http://127.0.0.1:${pending.port}/callback?error=access_denied&state=expected-state`,
);

expect(response.status).toBe(400);
await rejection;
});

it('ignores unrelated paths while continuing to wait', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 5_000,
});

const stray = await fetch(`http://127.0.0.1:${pending.port}/favicon.ico`);
expect(stray.status).toBe(404);

const response = await fetch(
`http://127.0.0.1:${pending.port}/callback?code=auth-code-2&state=expected-state`,
);
expect(response.status).toBe(200);
await expect(pending.callback).resolves.toEqual({ code: 'auth-code-2' });
});

it('times out with a clean error when no callback arrives', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 50,
});

await expect(pending.callback).rejects.toThrow(/timed out/i);
});

it('can be cancelled, releasing the port', async () => {
const pending = await startOAuthCallbackServer({
port: 0,
expectedState: 'expected-state',
timeoutMs: 5_000,
});

const cancelled = pending.cancel();
await expect(pending.callback).rejects.toThrow(/cancelled/i);
await cancelled;

await expect(
fetch(`http://127.0.0.1:${pending.port}/callback?code=x&state=expected-state`),
).rejects.toThrow();
});
});
Loading