linear-mcp-lean
A self-hosted Linear MCP server that provides hosted-compatible Linear tools with lean field-selected reads, minimal write acknowledgements, and a GraphQL escape hatch.
Read and search Linear issues, projects, teams, users, documents, labels, workflow states, cycles, attachments, initiatives, and status updates.
Create or update issues, comments, projects, milestones, documents, labels, attachments, and status updates with compact return payloads.
Filter and resolve names server-side for teams, states, projects, labels, assignees, milestones, and users; unresolved names error loudly.
Page through issue lists and optionally request richer
full: truefield sets when needed.Use proxied hosted-MCP tools for documentation search, image extraction, and diff/review-thread lookups.
Run arbitrary GraphQL queries or permitted mutations through
linear_graphql.Run locally over stdio or as a bearer-protected shared HTTP deploy with
/health,/ready, and/statsobservability endpoints.
Provides tools for interacting with Linear's API, enabling AI agents to manage issues, projects, teams, documents, attachments, and other Linear resources programmatically.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@linear-mcp-leanlist my open issues in the Engineering team"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
linear-mcp-lean
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.
Related MCP server: Context Guardian
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 — for the rare need the typed tools don't cover: a sanctioned named op (op+ variables → a server-held minimal query, trimmed result) or, for a genuine one-off, a raw GraphQL document (null-stripped result). Raw mutations needallowMutation: trueplus the service opt-in.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-leanor 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 /mcpMCP_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 (46 tools total):
Group | Tools |
Issues |
|
Projects |
|
Teams & users |
|
Labels & states |
|
Documents |
|
Attachments |
|
Initiatives |
|
Status updates |
|
Proxied to hosted MCP |
|
Escape hatch |
|
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,list_initiatives,get_initiative) → 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— the last resort:FIELDS.mdmaps the common "want X" to the typed tool that already does it. Then a sanctioned named op (project_comments,project_documents,issue_subscribers,issue_children,issue_history,type_shape— seeFIELDS.md); the server runs its canonical minimal query and returns a trimmed shape. A rawqueryruns againsthttps://api.linear.app/graphqlwith a null-stripped result; a mutation is rejected unless the call passesallowMutation: trueand the service setsLINEAR_GRAPHQL_ALLOW_MUTATION=1. Bearer-gated like every tool; errors surface, never swallowed.// named op → ["Ana", "Bo"] { "name": "linear_graphql", "arguments": { "op": "issue_subscribers", "variables": { "id": "ENG-123" } } } // raw one-off { "name": "linear_graphql", "arguments": { "query": "query($id:String!){ issue(id:$id){ identifier slaType } }", "variables": { "id": "ENG-123" } } }
Endpoints
Endpoint | Auth | Purpose |
| Bearer | The MCP endpoint (stateless Streamable HTTP) |
| none | Liveness — process up, deliberately no Linear call |
| Bearer | Readiness — fresh |
| 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 testLive 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.
Available Tools
36 toolscreate_attachmentCreate attachmentB
Link an external URL to an issue as an attachment → {id, title, url}. issue accepts an identifier or id.
Inputs: url (required), issue (required), title (required), subtitle.
create_attachment_from_uploadCreate attachment from uploadA
Finalize an upload: link an already-uploaded assetUrl to an issue → {id, title, url}. Call after prepare_attachment_upload + the client-side byte PUT.
Inputs: issue (required), assetUrl (required), title, subtitle.
create_issue_labelCreate issue labelA
Create an issue label → {id, name, color}. team (name or id) scopes it to a team; omit for a workspace label.
Inputs: name (required), team, color.
extract_imagesExtract imagesA
Extract and fetch images from markdown content. Proxied to the hosted Linear MCP (content helper, no GraphQL backing).
Inputs: markdown (required).
get_attachmentGet attachmentA
Get one attachment → {id, title, subtitle, url, sourceType}.
Inputs: id (required).
get_diffGet diffA
Exact lookup for a Linear diff (review URL, PR URL/ID, slug, or full identifier). Proxied to the hosted Linear MCP (no public GraphQL backing).
Inputs: urlOrId (required).
get_diff_threadsGet diff threadsC
Exact lookup for diff review threads. Proxied to the hosted Linear MCP (no public GraphQL backing).
Inputs: urlOrId (required), orderBy, resolved, threadId.
get_documentGet documentA
Get one document → {id, title, content, slugId, updatedAt, project{id}}. id accepts a document id or slug.
Inputs: id (required).
get_issueGet issueA
Retrieve a Linear issue by ID. Default → a minimal flattened field set; full:true → a documented richer superset (assignee, lifecycle timestamps, state type, parent, estimate, dueDate).
Inputs: id (required), full.
get_issue_statusGet issue statusA
Get one workflow state → {id, name, type, color}.
Inputs: id (required).
get_milestoneGet milestoneB
Get one project milestone (id, name, description).
Inputs: id (required).
get_projectGet projectA
Get one project. Default → id, name, description, labels[name]; full:true adds status{name,type}, dates, lead, initiatives.
Inputs: id (required), full.
get_status_updatesGet status updatesB
Get one status update by id, or list a project's/initiative's updates → [{id, body, health, createdAt, url, authorName}]. type selects project vs initiative.
Inputs: type (required), id, limit, project, +1 more.
get_teamGet teamA
Get one team by id, key, or name → {id, name, key}.
Inputs: query (required).
get_userGet userA
Get one user by id, "me", or name → {id, name, displayName, email, active}.
Inputs: query (required).
linear_graphqlLinear GraphQL (escape hatch)A
Run an arbitrary GraphQL query or mutation against Linear's API (https://api.linear.app/graphql) and return the raw result. For the rare need neither the lean default nor full:true covers. Example: linear_graphql({query: "query($id:String!){ issue(id:$id){ identifier subscribers{ nodes{ name } } } }", variables: {id: "ENG-123"}}). Bearer-gated like every tool; errors surface, never swallowed.
Inputs: query (required), variables.
list_commentsList commentsA
List an issue's comments (id, body, authorName, createdAt). issue accepts an identifier (e.g. ENG-123) or id.
Inputs: issue (required).
list_cyclesList cyclesA
List cycles → [{id, number, name, startsAt, endsAt}]. Optional team (name or id) scope.
Inputs: team, limit.
list_diffsList diffsB
List Linear diff pull requests. Proxied to the hosted Linear MCP (no public GraphQL backing).
Inputs: repo, limit, owner, query, +3 more.
list_documentsList documentsA
List documents as lean rows → [{id, title, slugId, updatedAt}] (no content).
Inputs: limit.
list_issue_labelsList issue labelsA
List issue labels → [{id, name, color, isGroup}]. Optional name filter.
Inputs: name, limit.
list_issuesList issuesA
List issues as a {issues, hasNextPage, cursor} envelope. Lean rows by default (identifier, title, state, statusType, priority, createdAt, blockedBy, labels, project{id}, projectMilestone{id}, gitBranchName); full:true adds description, url, assignee, milestone name, updatedAt per row. query is a case-insensitive title/description text search (find an existing ticket without raw graphql). Page forward by passing the returned cursor back. Filter names (state/project/label/assignee/team) resolve server-side; an unresolved name errors loudly. Narrow server-side with team and includeCompleted:false (excludes completed/canceled/duplicate) to cut row count before it reaches you.
Inputs: full, team, label, limit, +6 more.
list_issue_statusesList issue statusesA
List workflow states → [{id, name, type, color}]. Optional team (name or id) scope.
Inputs: team, limit.
list_milestonesList milestonesA
List a project's milestones (id, name). project accepts a name or id.
Inputs: project (required).
list_project_labelsList project labelsA
List project labels → [{id, name, color, isGroup}]. Optional name filter.
Inputs: name, limit.
list_projectsList projectsA
List projects as lean rows (id, name, status{name,type}) by default; full:true adds description, dates, lead, labels, initiatives. Optional state (lifecycle string), label (project label name), and team filters; includeCompleted:false excludes completed/canceled projects server-side.
Inputs: full, team, label, limit, +2 more.
list_teamsList teamsA
List teams → [{id, name, key}]. query filters by name/key substring.
Inputs: limit, query.
list_usersList usersA
List users → [{id, name, displayName, email, active}]. query filters by name/displayName/email substring.
Inputs: limit, query.
prepare_attachment_uploadPrepare attachment uploadA
Get a presigned direct-upload URL (fileUpload) → {assetUrl, uploadUrl, headers, issue}. PUT raw bytes to uploadUrl with headers verbatim (client-side), then call create_attachment_from_upload.
Inputs: size (required), issue (required), filename (required), contentType (required), +2 more.
save_commentSave commentA
Add a comment to an issue. Returns only {id, url}. issue accepts an identifier or id.
Inputs: body (required), issue (required).
save_documentSave documentA
Create (no id) or update (id) a document → {id, title, slugId}. Create requires title; project (name or id) resolves server-side.
Inputs: id, title, content, project.
save_issueSave issueA
Create (no id) or update (id) an issue. Returns only {id, identifier, state, url} — no full-object echo. id-or-name args (state/assignee/project/milestone/labels) resolve server-side; blockedBy takes issue identifiers; create requires title + team.
Inputs: id, team, state, title, +7 more.
save_milestoneSave milestoneA
Create (no id) or update (id) a project milestone. Returns only {id, name}. Create requires name + project (name or id).
Inputs: id, name, project, description.
save_projectSave projectA
Create (no id) or update (id) a project. Returns only {id, name, url}. Create requires name + team; addInitiatives (names or ids) are attached after create.
Inputs: id, name, team, description, +1 more.
save_status_updateSave status updateA
Create (no id) or update (id) a project/initiative status update → {id, url, health}. Create requires the matching parent (project or initiative).
Inputs: type (required), id, body, health, +2 more.
search_documentationSearch documentationB
Search Linear's help-center documentation. Proxied to the hosted Linear MCP (no public GraphQL backing).
Inputs: query (required), page.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
TDQS
Scored across 36 tools
Each tool targets a distinct operation on a specific entity (issue, project, document, etc.). Even similar pairs like create_attachment vs create_attachment_from_upload are clearly differentiated by their parameters and process. The raw GraphQL tool is explicitly a fallback, avoiding ambiguity.
Tool names consistently follow a verb_noun pattern (e.g., create_issue_label, list_issues, save_issue). The only deviation is linear_graphql, but that is a special-purpose tool, and the pattern holds for all others. Naming is predictable and uniform.
With 36 tools, the server is comprehensive but slightly heavy. It covers many Linear entities, but the count is above the typical well-scoped range (3–15). Some tools like extract_images and search_documentation are auxiliary, and the diff-related tools add redundancy. Still, the count is reasonable given the domain's complexity.
The tool surface lacks delete operations for issues, projects, documents, attachments, comments, milestones, and labels. There are no update operations for labels or comments (save_comment only creates, not updates). These gaps force agents to rely on the raw graphql tool for basic lifecycle management, which is a significant shortcoming.
Maintenance
Related MCP Connectors
LLM cost optimization gateway, repo token-waste auditor & model arbitrage MCP server.
MCP server for secureFlows: token-free URL builders and integration-linting tools for AI agents.
Hosted MCP server for LLM cost estimation, model comparison, and budget-aware routing.
MCP server connecting AI agents to 100+ apps (Gmail, Slack, Notion, GitHub) via one-click OAuth.
Related MCP Servers
- AlicenseAqualityBmaintenanceMCP server that reduces token consumption in AI coding assistants by up to 90% via structural reads, PreToolUse hooks, and tp-\* subagents.25217 npm5MIT
- AlicenseNot gradedqualityCmaintenanceMCP server that cuts cloud LLM costs 36-42% by indexing context locally and giving agents precision retrieval tools instead of raw context dumps.18 npm7MIT
- FlicenseNot gradedqualityDmaintenanceSelf-hosted MCP server with 83+ tools for AI workflows. Features 80%+ token reduction through 5 optimization layers.-
- AlicenseNot gradedqualityCmaintenanceMCP server that reduces AI agent token usage by up to 90% through intelligent context compression. Enables efficient code exploration, multi-file refactoring, and debugging by providing tools for smart reading, searching, and managing code context.4MIT