Streamable HTTP MCP server for Linear — manage issues, projects, teams, cycles, and comments.
Author: overment
Warning
You connect this server to your MCP client at your own responsibility. Language models can make mistakes, misinterpret instructions, or perform unintended actions. Review tool outputs, verify changes (e.g., with list_issues), and prefer small, incremental writes.
Remote deployments must use HTTPS, strict Host/Origin allowlists, encrypted token storage, and production observability. The server validates opaque MCP Resource Server tokens against its stored mapping and never forwards an inbound MCP bearer token to Linear.
Below is a comparison between the official Linear MCP (top) and this MCP (bottom).
This repo works in two ways:
- As a fetch-native Bun server for local workflows
- As a fetch-native Cloudflare Worker for remote interactions
Important
This branch targets the 2026-07-28 protocol candidate with exact @modelcontextprotocol/server@2.0.0-beta.5 and @modelcontextprotocol/client@2.0.0-beta.5. It is release-candidate validation, not a claim of final specification conformance.
Both runtimes create one MCP handler per process/isolate and a fresh McpServer for every request. Modern requests are sessionless; legacy 2025-11-25 clients use the SDK's stateless fallback.
For production Cloudflare deployments, see Remote Model Context Protocol servers (MCP).
I'm a big fan of Linear and use it daily. At the time of writing, the official MCP server isn't fully optimized for language models. This server is built with key goals in mind:
- Let LLMs find Team IDs, Project IDs, Status IDs, or User IDs in a single action (
workspace_metadata) instead of multiple tool calls - Include clear MCP instructions and schema descriptions that cut API jargon
- Map API responses into human-readable feedback — useful for both the LLM and user
- Provide hints and suggestions for next steps, plus tips on recovering from errors
- Support batch actions (e.g.,
create_issuesinstead ofcreate_issue) so the LLM can perform multiple steps in one go - Prefetch related values — return both a status ID and actual status name for an issue
- Hide tools not enabled in a given team's settings (like
list_cycles) to reduce noise
In short, it's not a direct mirror of Linear's API — it's tailored so AI agents know exactly how to use it effectively.
- ✅ Issues — List, search, create, update (state, assignee, labels, priority, etc.)
- ✅ Projects — List, create, update projects
- ✅ Teams & Users — Discover workspace structure
- ✅ Cycles — Browse sprint/cycle planning
- ✅ Comments — List and add comments on issues
- ✅ OAuth 2.1 — Secure PKCE flow with RS token mapping
- ✅ Dual Runtime — Bun or Cloudflare Workers
- ✅ Production Ready — Encrypted token storage, rate limiting, multi-user support
- LLM-friendly: Tools are simplified and unified, not 1:1 API mirrors
- Batch-first: Create/update operations accept arrays to minimize tool calls
- Discovery-first:
workspace_metadatareturns all IDs needed for subsequent calls - Clear feedback: Every response includes human-readable summaries with diffs
Prerequisites: Bun, a Linear account, and—when deploying remotely—a Cloudflare account.
- Local (API key) — Fastest start
- Local + OAuth — For multi-user or token refresh
- Cloudflare Worker (wrangler dev) — Local Worker testing
- Cloudflare Worker (deploy) — Remote production
Run the server with your Linear Personal Access Token from Settings → Security.
git clone <repo>
cd linear-mcp
bun install
cp env.example .envEdit .env:
PORT=3000
AUTH_ENABLED=false
AUTH_STRATEGY=none
LINEAR_ACCESS_TOKEN=lin_api_xxxxbun run dev
# MCP: http://127.0.0.1:3000/mcpConnect the MCP client to http://localhost:3000/mcp without forwarding the Linear token as an MCP bearer credential. LINEAR_ACCESS_TOKEN is deployment-scoped provider authorization; it is not MCP caller authentication.
More advanced — requires creating an OAuth application in Linear.
- Create an OAuth app at Linear Settings → API → OAuth Applications
- Set redirect URIs:
http://127.0.0.1:3001/oauth/callback alice://oauth/callback - Copy Client ID and Secret
cp env.example .envEdit .env:
PORT=3000
AUTH_ENABLED=true
PROVIDER_CLIENT_ID=your_client_id
PROVIDER_CLIENT_SECRET=your_client_secret
OAUTH_SCOPES=read write
OAUTH_REDIRECT_URI=alice://oauth/callback
OAUTH_REDIRECT_ALLOWLIST=alice://oauth/callback,http://127.0.0.1:3001/oauth/callbackbun dev
# MCP: http://127.0.0.1:3000/mcp
# OAuth: http://127.0.0.1:3001Tip: The Authorization Server runs on PORT+1.
Claude Desktop:
{
"mcpServers": {
"linear": {
"command": "bunx",
"args": ["mcp-remote", "http://localhost:3000/mcp", "--transport", "http-only"],
"env": { "NO_PROXY": "127.0.0.1,localhost" }
}
}
}Enable these flags to require RS-minted bearer tokens:
When enabled, requests without Authorization or with non-mapped tokens receive 401 with WWW-Authenticate so OAuth can start.
bun run dev:workerWith OAuth, configure the Linear application credentials as Worker secrets:
bunx wrangler secret put PROVIDER_CLIENT_ID --config wrangler.jsonc
bunx wrangler secret put PROVIDER_CLIENT_SECRET --config wrangler.jsonc
bun run dev:workerEndpoint: http://127.0.0.1:8787/mcp
- Create KV namespace:
bunx wrangler kv namespace create TOKENS --config wrangler.jsonc-
Configure the
TOKENSbinding inwrangler.jsonc(or let Wrangler provision it) -
Set secrets:
bunx wrangler secret put PROVIDER_CLIENT_ID --config wrangler.jsonc
bunx wrangler secret put PROVIDER_CLIENT_SECRET --config wrangler.jsonc
# Generate encryption key (32-byte base64url):
openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'
bunx wrangler secret put RS_TOKENS_ENC_KEY --config wrangler.jsoncNote:
RS_TOKENS_ENC_KEYencrypts OAuth tokens stored in KV (AES-256-GCM).
-
Update the redirect URI and allowlist in
wrangler.jsonc -
Set
MCP_PUBLIC_URL, Host/Origin allowlists, and the Worker callback URL inwrangler.jsonc; add the callback to the Linear OAuth app -
Deploy:
bun run deployEndpoint: https://<worker-name>.<account>.workers.dev/mcp
MCP Inspector (quick test):
bunx @modelcontextprotocol/inspector
# Connect to: http://localhost:3000/mcpClaude Desktop / Cursor:
{
"mcpServers": {
"linear": {
"command": "bunx",
"args": ["mcp-remote", "http://127.0.0.1:3000/mcp", "--transport", "http-only"],
"env": { "NO_PROXY": "127.0.0.1,localhost" }
}
}
}For Cloudflare, replace URL with https://<worker-name>.<account>.workers.dev/mcp.
Discover workspace entities and IDs. Call this first when you don't know IDs.
// Input
{
include?: ("profile"|"teams"|"workflow_states"|"labels"|"projects"|"favorites")[];
teamIds?: string[];
project_limit?: number;
label_limit?: number;
}
// Output
{
viewer: { id, name, email, displayName, timezone };
teams: Array<{ id, key, name, cyclesEnabled, defaultIssueEstimate }>;
workflowStatesByTeam: Record<teamId, Array<{ id, name, type }>>;
labelsByTeam: Record<teamId, Array<{ id, name, color }>>;
projects: Array<{ id, name, state, teamId, leadId, targetDate }>;
}Search and filter issues with powerful GraphQL filtering.
// Input
{
teamId?: string;
projectId?: string;
filter?: IssueFilter; // GraphQL-style: { state: { type: { eq: "started" } } }
q?: string; // Title search tokens
keywords?: string[]; // Alternative to q
includeArchived?: boolean;
orderBy?: "updatedAt" | "createdAt";
limit?: number; // 1-100
cursor?: string; // Pagination
fullDescriptions?: boolean;
}
// Output
{
items: Array<{
id, identifier, title, description?,
stateId, stateName, projectId?, projectName?,
assigneeId?, assigneeName?, labels[], dueDate?, url
}>;
cursor?: string;
nextCursor?: string;
limit: number;
}Create multiple issues in one call.
{
items: Array<{
teamId: string;
title: string;
description?: string;
stateId?: string;
labelIds?: string[];
assigneeId?: string; // Defaults to current viewer
projectId?: string;
priority?: number; // 0-4
estimate?: number;
dueDate?: string; // YYYY-MM-DD
parentId?: string;
}>;
parallel?: boolean;
}Update issues in batch (state, labels, assignee, metadata).
{
items: Array<{
id: string;
title?: string;
description?: string;
stateId?: string;
labelIds?: string[];
addLabelIds?: string[]; // Incremental add
removeLabelIds?: string[]; // Incremental remove
assigneeId?: string;
projectId?: string;
priority?: number;
estimate?: number;
dueDate?: string;
archived?: boolean;
}>;
parallel?: boolean;
}get_issues— Fetch issues by ID (batch)list_projects/create_projects/update_projects— Manage projectslist_teams/list_users— Discover workspace structurelist_cycles— Browse team cycles (if enabled)list_comments/add_comments— Issue comments
// First, get viewer info
{ "name": "workspace_metadata", "arguments": { "include": ["profile"] } }
// Then list issues assigned to me
{
"name": "list_issues",
"arguments": {
"assignedToMe": true,
"filter": { "dueDate": { "eq": "2025-08-15" } },
"orderBy": "updatedAt",
"limit": 20
}
}Response:
Issues: 1 (limit 20). Preview:
- [OVE-142 — Publish release notes](https://linear.app/.../OVE-142) — state Done; due 2025-08-15
// Discover IDs first
{ "name": "workspace_metadata", "arguments": { "include": ["teams", "projects"] } }
// Create (assigneeId defaults to current viewer)
{
"name": "create_issues",
"arguments": {
"items": [{
"title": "Release Alice v3.8",
"teamId": "TEAM_ID",
"projectId": "PROJECT_ID",
"dueDate": "2025-08-18",
"priority": 2
}]
}
}Response:
Created issues: 1 / 1. OK: item[0].
Next: Use list_issues to verify details.
// Resolve workflow states first
{ "name": "workspace_metadata", "arguments": { "include": ["workflow_states"], "teamIds": ["TEAM_ID"] } }
// Update both issues
{
"name": "update_issues",
"arguments": {
"items": [
{ "id": "RELEASE_UUID", "dueDate": "2025-08-16" },
{ "id": "MEETING_UUID", "stateId": "DONE_STATE_ID" }
]
}
}Response:
Updated issues: 2 / 2. OK: RELEASE_UUID, MEETING_UUID
- [OVE-231 — Release Alice v3.8] Due date: 2025-08-18 → 2025-08-16
- [OVE-224 — Team meeting] State: Current → Done
| Endpoint | Method | Purpose |
|---|---|---|
/mcp |
POST | MCP JSON-RPC 2.0 |
/mcp |
GET / DELETE | 405 (modern and stateless legacy HTTP do not expose session endpoints) |
/health |
GET | Health check |
/.well-known/oauth-authorization-server |
GET | OAuth AS metadata |
/.well-known/oauth-protected-resource |
GET | OAuth RS metadata |
OAuth (PORT+1):
GET /authorize— Start OAuth flowGET /oauth/callback— Provider callbackPOST /token— Token exchangePOST /revoke— Revoke tokens
bun run dev # Start with hot reload
bun run typecheck # TypeScript check
bun run lint # Lint code
bun run build # Production build
bun start # Run productionThe project uses a two-layer testing strategy:
Fast tests using mocked Linear API responses. Tests all logic, validation, and edge cases without network calls.
bun run test # Run unit, protocol, storage, and configured live tests
bun run test:watch # Watch mode
bun run test:coverage # With coverage reportReal API tests that verify the actual Linear connection works. Creates issues in a "Tests" team and cleans up after.
Setup:
- Create a team named "Tests" in your Linear workspace
- Add your Linear API key to
.env:PROVIDER_API_KEY=lin_api_xxxx
Run:
bun run test:integration # ~45 secondsWhat it tests:
| Category | Tests | Purpose |
|---|---|---|
| CRUD | 5 | Create, Read, Update, List operations |
| Filtering | 3 | Priority, title search, workflow state filters |
| Pagination | 2 | Limit and cursor behavior |
| Errors | 3 | Non-existent issues, invalid filters |
| Rate Limiting | 2 | Rapid requests, batch operations |
| Layer | Speed | Purpose |
|---|---|---|
| Unit/Mock | ⚡️ Fast | Logic correctness, validation, edge cases |
| Integration | 🐢 Slow | API contract, real data mapping |
| TypeScript | 🛡️ Build | SDK type alignment |
Run unit tests on every change. Run integration tests before releases or after SDK upgrades.
bun run test:protocol uses the official beta.5 client in modern and legacy modes. It verifies the 15-tool snapshot and order, structured outputs, mocked GraphQL success/failure, the issues UI resource, cache hints, subscriptions, cancellation, transport security, OAuth metadata/errors, provider-token separation, and concurrent principal isolation.
The dated protocol is still a release candidate. Re-run the complete Bun and workerd matrix against final packages before claiming final conformance.
src/
├── shared/
│ ├── tools/
│ │ └── linear/ # Tool definitions (work in Bun + Workers)
│ │ ├── workspace-metadata.ts
│ │ ├── list-issues.ts
│ │ ├── create-issues.ts
│ │ ├── update-issues.ts
│ │ ├── projects.ts
│ │ ├── comments.ts
│ │ ├── cycles.ts
│ │ └── shared/ # Formatting, validation, snapshots
│ ├── oauth/ # OAuth flow (PKCE, discovery)
│ └── storage/ # Token storage (file, KV, memory)
├── services/
│ └── linear/
│ └── client.ts # LinearClient wrapper with auth
├── schemas/
│ ├── inputs.ts # Zod input schemas
│ └── outputs.ts # Zod output schemas
├── config/
│ └── metadata.ts # Server & tool descriptions
├── index.ts # Bun entry
└── worker.ts # Workers entry
The MCP access token, Linear access token, and Linear refresh token are separate credentials. The opaque MCP verifier places only linearProviderAccessToken in AuthInfo.extra; tools never read or forward authInfo.token, and refresh tokens remain in file/KV storage.
The migration does not change OAuth record shapes or KV key names. File records remain { version: 1, encrypted, records }, and KV mappings remain under rs:access:* and rs:refresh:*. Rolling back to the recorded pre-v2 SHA can read records written by this candidate, and this candidate can read pre-v2 records. No destructive storage migration is required.
| Issue | Solution |
|---|---|
| "Workspace does not exist" | Verify your OAuth app is in the correct Linear workspace. Check PROVIDER_CLIENT_ID. |
| "Unauthorized" | Complete OAuth flow. Tokens may have expired. |
| "State not found" | Use workspace_metadata to get valid stateIds for the team. |
| "Rate limited" | Linear has strict rate limits. Wait and retry. |
| OAuth doesn't start (Worker) | curl -i -X POST https://<worker>/mcp should return 401 with WWW-Authenticate. |
| Tools empty in Claude | Ensure Worker returns JSON Schema for tools/list; use mcp-remote. |
MIT
