Skip to main content
Glama
linear-mcp-lean

linear-mcp-lean

by wiklob

linear-mcp-lean

CI savings re-measured weekly

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: true on that one call, or drop to raw GraphQL with linear_graphql. Lean by default, never a dead end.

  • You don't have to take the savings on faith: every call is logged, and GET /stats shows 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 /stats byte-log). Both serve the same buildServer() 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_graphql escape 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 need allowMutation: true plus the service opt-in.

  • Byte-savings observability — every call appends one JSONL record (upstream bytes vs bytes returned); GET /stats aggregates 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 (46 tools total):

Group

Tools

Issues

get_issue, list_issues, save_issue, list_comments, save_comment, create_issue_relation, list_issue_relations, delete_issue_relation

Projects

get_project, list_projects, save_project, list_milestones, get_milestone, save_milestone, delete_milestone

Teams & users

get_team, list_teams, save_team, get_user, list_users

Labels & states

list_issue_labels, list_project_labels, create_issue_label, list_issue_statuses, get_issue_status, list_cycles, list_git_automation_states, delete_git_automation_state

Documents

get_document, list_documents, save_document

Attachments

get_attachment, create_attachment, prepare_attachment_upload, create_attachment_from_upload

Initiatives

list_initiatives, get_initiative, save_initiative

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 on get_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_issues returns a pagination envelope matching the hosted MCP shape:

    { "issues": [ … ], "hasNextPage": true, "cursor": "<opaque token>" }

    To page: pass the returned cursor back as the cursor arg until hasNextPage is false. list_issues is the only paginated tool (see FIELDS.md for why the other lists stay bare arrays).

  • linear_graphql — the last resort: FIELDS.md maps 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 — see FIELDS.md); the server runs its canonical minimal query and returns a trimmed shape. A raw query runs against https://api.linear.app/graphql with a null-stripped result; a mutation is rejected unless the call passes allowMutation: true and the service sets LINEAR_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

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_TOKEN fails closed (500), never open.

  • /ready and /stats are bearer-gated too — they expose the viewer id and traffic shape.

  • Secrets come only from the environment (.env locally, a root-owned env file under systemd). Only .env.example is committed; npm run probe:secrets asserts nothing secret is tracked.

License

MIT — see LICENSE.

Available Tools

36 tools
create_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

    A3.6/5.0

    Scored across 36 tools

    Disambiguation5/5

    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.

    Naming Consistency5/5

    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.

    Tool Count3/5

    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.

    Completeness2/5

    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

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      MCP server that cuts cloud LLM costs 36-42% by indexing context locally and giving agents precision retrieval tools instead of raw context dumps.
      18 npm
      7
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      MCP 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.
      4
      MIT