Linear Lean
FreeNot checkedA self-hosted MCP server for Linear that reduces response sizes by field-selecting minimal data, saving tokens and costs in LLM agent sessions.
About
A self-hosted MCP server for Linear that reduces response sizes by field-selecting minimal data, saving tokens and costs in LLM agent sessions.
README
A self-hosted MCP server for Linear that controls the response shape: field-selected, flattened reads and minimal write acks instead of the fat full-object payloads the hosted Linear MCP returns. Run it locally over stdio (npx linear-mcp-lean) or as a shared HTTP deploy.
Built for LLM agents (Claude Code, or any MCP client) that call Linear tools hundreds of times per session: every byte a tool returns is a token your model pays to read. This wrapper serves the same tool names as the hosted Linear MCP, so it's a drop-in replacement — but a default list_issues row is ~0.3× the hosted ~1.2 KB/issue, and a save_issue ack is ~160 bytes with no full-object echo.
In plain words
- Your agent talks to Linear through this server instead of Linear's own MCP server. Same tool names, same behavior — nothing in your prompts or workflows changes.
- The difference is what comes back: only the fields agents actually use, not the whole object. A typical issue row is ~340 bytes here vs ~1.2 KB from the hosted server; a write returns a tiny receipt (
{id, identifier, state, url}) instead of echoing the full issue back. - Fewer bytes returned = fewer tokens your model reads = cheaper, faster agent sessions — the savings compound over hundreds of calls.
- Need more sometimes? Pass
full: trueon that one call, or drop to raw GraphQL withlinear_graphql. Lean by default, never a dead end. - You don't have to take the savings on faith: every call is logged, and
GET /statsshows the trim ratio measured on your own traffic. - It authenticates with a plain Linear API key — no OAuth dance — so it works headless: CI, cron, background agents, multiple machines sharing one deploy.
Measured savings vs the hosted MCP
Measured 2026-07-07 against a live deploy — identical requests to this wrapper and to the hosted Linear MCP, response bytes compared per call (per row for lists). The probe-vs-hosted workflow re-measures weekly and fails on regression:
tool w.bytes h.bytes w.rows h.rows save%call save%/row
--------------------------------------------------------------------------------
get_issue 1422 2138 0 0 33.5% —
get_project 311 1100 0 0 71.7% —
get_team 83 148 1 1 43.9% —
list_teams 618 1256 8 8 50.8% 50.8%
list_projects 270 2025 2 2 86.7% 86.7%
list_issues 2788 9402 6 6 70.3% 70.3%
list_issue_statuses 697 571 7 7 -22.1% -22.1%
--------------------------------------------------------------------------------
TOTAL wrapper=6189B (~1768 tok) hosted=16640B (~4754 tok) aggregate save=62.8%
How it compares
- vs Linear's hosted MCP (mcp.linear.app) — identical tool names, so it's a drop-in swap; but reads are field-trimmed to roughly a third of the size and writes return minimal acks instead of full-object echoes. Static API key instead of per-user OAuth, which is what makes headless use possible. The 5 tools Linear's public GraphQL can't back are proxied to the hosted server, so nothing is lost in the swap.
- vs community SDK wrappers (cline/linear-mcp, tacticlaunch/mcp-linear, dvcrn/mcp-server-linear, …) — those expose plain CRUD with untrimmed SDK payloads. This serves field-trimmed responses — response size is the entire point of its design — over the same convenient stdio transport (
npx linear-mcp-lean), plus an HTTP mode one deploy can share across machines and agents. - vs linear-toon-mcp — same motivation (the hosted MCP burns context), different mechanism: TOON re-encodes all the data in a more compact text format (~40–60% claimed). This server instead selects fields server-side, so unneeded data never crosses the wire at all, stays plain JSON (no extra format for the model to parse), keeps the hosted server's tool names for drop-in compatibility, and proves its savings from live traffic via
/stats.
How it works
MCP client ──stdio (npx linear-mcp-lean)──────────────▶ this server
or ──POST /mcp (Bearer MCP_BEARER_TOKEN)──────▶ │
│
hand-written minimal GraphQL ├──▶ api.linear.app/graphql (LINEAR_API_KEY)
verbatim proxy (5 tools) └──▶ mcp.linear.app/mcp (LINEAR_API_KEY)
Two transports, one tool set — stdio (local child process of your MCP client, one command, no server to run) and stateless Streamable HTTP behind a bearer gate (one deploy shared across machines, plus the
/statsbyte-log). Both serve the samebuildServer()registrations.Trimmed GraphQL tools — each read is a hand-written minimal GraphQL query flattened into a closed object (never a spread of the raw response, so no field sneaks in); each write returns a minimal ack (
save_issue→{id, identifier, state, url}).Server-side name→id resolution — filter and write args accept names (
state: "In Progress",project: "My Project",assignee: "me", team key/name case-insensitively); an unresolved name throws a loud error, never a silent empty result.Hosted-MCP proxy fallback — 5 tools Linear's public GraphQL cannot back (
search_documentation,extract_images,get_diff,get_diff_threads,list_diffs) are forwarded verbatim to the hosted Linear MCP.linear_graphqlescape hatch — run an arbitrary GraphQL document and get the raw, untrimmed result, for the rare need the lean defaults don't cover.Byte-savings observability — every call appends one JSONL record (upstream bytes vs bytes returned);
GET /statsaggregates per-tool trim ratios from real traffic.
Stack
@modelcontextprotocol/sdk (McpServer + stateless StreamableHTTPServerTransport) behind Express POST /mcp; graphql-request for the hand-written queries; zero database.
Quick start
Requires Node 20+ and a Linear Personal API key (Settings → Security & access → Personal API keys).
Local (stdio) — no server to run
# with LINEAR_API_KEY exported in your shell:
claude mcp add linear -e LINEAR_API_KEY=${LINEAR_API_KEY} -- npx -y linear-mcp-lean
or in .mcp.json / ~/.claude.json mcpServers (any MCP client with stdio support):
{
"mcpServers": {
"linear": {
"command": "npx",
"args": ["-y", "linear-mcp-lean"],
"env": { "LINEAR_API_KEY": "${LINEAR_API_KEY}" }
}
}
}
(${LINEAR_API_KEY} is expanded from the client's environment at session start.) No bearer token here: a stdio server is a local child process of your MCP client, so the only credential is the outbound LINEAR_API_KEY. The byte log is off in stdio mode unless you set BYTE_LOG_PATH explicitly.
Hosted (HTTP) — one deploy shared across machines and agents
npm install
cp .env.example .env # fill in MCP_BEARER_TOKEN + LINEAR_API_KEY
npm run build
npm start # listens on :$PORT (default 8080), MCP at POST /mcp
MCP_BEARER_TOKEN— inbound auth: the token your MCP clients must send. Generate one:openssl rand -hex 32.LINEAR_API_KEY— outbound auth: the same Personal API key as above.
Smoke test:
source .env
curl -s http://localhost:8080/health # {"ok":true} — liveness, no Linear call
curl -s -H "Authorization: Bearer $MCP_BEARER_TOKEN" \
http://localhost:8080/ready # proves the LINEAR_API_KEY actually reaches Linear
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $MCP_BEARER_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_issue","arguments":{"id":"ENG-123"}}}'
Connect an MCP client
Register under the server name linear so tool names (mcp__linear__* in Claude Code) match the hosted Linear MCP exactly — existing prompts and call sites keep working unchanged. For the stdio form see Quick start; connecting to a hosted deploy:
Claude Code (CLI):
claude mcp add --transport http linear https://linear-mcp.example.com/mcp \
--header "Authorization: Bearer ${MCP_BEARER_TOKEN}"
or in .mcp.json / ~/.claude.json mcpServers:
{
"mcpServers": {
"linear": {
"type": "http",
"url": "https://linear-mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_BEARER_TOKEN}" }
}
}
}
(${MCP_BEARER_TOKEN} is expanded from the client's environment at session start.)
For local experimentation, point at http://localhost:8080/mcp instead.
Tools
Same names and semantics as the hosted Linear MCP (36 tools total):
| Group | Tools |
|---|---|
| Issues | get_issue, list_issues, save_issue, list_comments, save_comment |
| Projects | get_project, list_projects, save_project, list_milestones, get_milestone, save_milestone |
| Teams & users | get_team, list_teams, get_user, list_users |
| Labels & states | list_issue_labels, list_project_labels, create_issue_label, list_issue_statuses, get_issue_status, list_cycles |
| Documents | get_document, list_documents, save_document |
| Attachments | get_attachment, create_attachment, prepare_attachment_upload, create_attachment_from_upload |
| Status updates | get_status_updates, save_status_update |
| Proxied to hosted MCP | search_documentation, extract_images, get_diff, get_diff_threads, list_diffs |
| Escape hatch | linear_graphql |
Field contract — lean default, broaden on demand
Reads are lean by default, broaden on demand. The full per-tool field map lives in FIELDS.md; the contract in brief:
full: true(opt-in onget_issue,list_issues,list_projects,get_project) → a documented richer superset (assignee, lifecycle timestamps, state type, parent, estimate, due date, …). Absent → the minimal contract. You only pay the extra bytes when you ask.list_issuesreturns a pagination envelope matching the hosted MCP shape:{ "issues": [ … ], "hasNextPage": true, "cursor": "<opaque token>" }To page: pass the returned
cursorback as thecursorarg untilhasNextPageis false.list_issuesis the only paginated tool (seeFIELDS.mdfor why the other lists stay bare arrays).linear_graphql({query, variables})— arbitrary GraphQL againsthttps://api.linear.app/graphqlvia the same server-side client, raw untrimmed result. Bearer-gated like every tool; errors surface, never swallowed.// request { "name": "linear_graphql", "arguments": { "query": "query($id:String!){ issue(id:$id){ identifier subscribers{ nodes{ name } } } }", "variables": { "id": "ENG-123" } } }
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /mcp |
Bearer | The MCP endpoint (stateless Streamable HTTP) |
GET /health |
none | Liveness — process up, deliberately no Linear call |
GET /ready |
Bearer | Readiness — fresh viewer query proves the LINEAR_API_KEY authenticates (catches a placeholder/revoked key /health can't); 503 + surfaced error on failure |
GET /stats |
Bearer | Per-tool + overall upstream/downstream byte totals and trim ratios, plus byte-log write-health |
Tests and verification probes
Unit tests cover the flatteners and name→id resolvers with fetch mocked at the GraphQL seam — no Linear workspace, key, or network needed (they run on fork PRs):
npm test
Live invariants are covered by encoded, runnable probes:
npm run build
npm run probe:auth # missing/invalid bearer -> 401; valid -> non-401 (key-free)
npm run probe:tools # tools/list serves every promised tool name (key-free)
npm run probe:bytelog # dead byte-log sink is distinguishable from idle on /stats (key-free)
npm run probe:secrets # no secret tracked in the repo (offline)
npm run probe:status # no deprecated GraphQL field selected (needs LINEAR_API_KEY)
npm run probe:proxy # hosted MCP accepts the PAK bearer (needs LINEAR_API_KEY)
npm run probe:vs-hosted # byte-savings comparison vs the hosted MCP (needs a deploy; see file header)
CI runs the type-check, build, unit tests, and the four key-free probes (auth, tools, bytelog, secrets) on every PR and push to main. The probe-vs-hosted workflow re-measures the savings weekly and fails when the aggregate drops below its floor; refresh the committed table with scripts/update-readme-savings.mjs when the numbers meaningfully change.
Deploy
deploy/ has a complete runbook (deploy/README.md) for a small Linux VPS: systemd unit (hardened: ProtectSystem=strict, dedicated no-login user, root-owned chmod 600 env file) + Caddy for automatic HTTPS.
Security notes
- Single-tenant by design. The server holds ONE Linear API key; every client presenting the bearer token acts as that Linear user, with that user's full workspace access. Don't share the bearer across trust boundaries — this is a personal/team-internal service, not a multi-tenant gateway.
- The bearer gate runs before the MCP transport and compares tokens with a timing-safe equality; a missing
MCP_BEARER_TOKENfails closed (500), never open. /readyand/statsare bearer-gated too — they expose the viewer id and traffic shape.- Secrets come only from the environment (
.envlocally, a root-owned env file under systemd). Only.env.exampleis committed;npm run probe:secretsasserts nothing secret is tracked.
License
MIT — see LICENSE.
Install Linear Lean in Claude Desktop, Claude Code & Cursor
unyly install linear-mcp-leanInstalls into Claude Desktop, Claude Code, Cursor & VS Code — handles npx, uvx and build-from-source repos for you.
First time? Get the CLI: curl -fsSL https://unyly.org/install | sh
Or configure manually
Run in your terminal:
claude mcp add linear-mcp-lean -- npx -y linear-mcp-leanFAQ
Is Linear Lean MCP free?
Yes, Linear Lean MCP is free — one-click install via Unyly at no cost.
Does Linear Lean need an API key?
No, Linear Lean runs without API keys or environment variables.
Is Linear Lean hosted or self-hosted?
A hosted option is available: Unyly runs the server in the cloud, no local setup required.
How do I install Linear Lean in Claude Desktop, Claude Code or Cursor?
Open Linear Lean on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
Notion
Read and write pages in your workspace
by NotionLinear
Issues, cycles, triage — from Claude
by LinearGoogle Drive
Search and read your Drive files
by Googlemindsdb/mindsdb
Connect and unify data across various platforms and databases with [MindsDB as a single MCP server](https://docs.mindsdb.com/mcp/overview).
by mindsdbCompare Linear Lean with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All productivity MCPs
