{"_id":"linear-mcp-lean","name":"linear-mcp-lean","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"linear-mcp-lean","version":"0.1.0","type":"module","description":"Self-hosted MCP server wrapping Linear's GraphQL API with response-shape control — lean reads, minimal write acks, drop-in tool names.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/wiklob/linear-mcp-lean.git"},"homepage":"https://github.com/wiklob/linear-mcp-lean#readme","bugs":{"url":"https://github.com/wiklob/linear-mcp-lean/issues"},"keywords":["mcp","mcp-server","model-context-protocol","linear","llm","agent"],"bin":{"linear-mcp-lean":"dist/stdio.js"},"engines":{"node":">=20"},"scripts":{"build":"tsc","prepack":"npm run build","start":"node dist/index.js","dev":"tsc --watch","test":"vitest run","probe:auth":"bash ./probe-auth.sh","probe:tools":"bash ./probe-tools.sh","probe:bytelog":"bash ./probe-bytelog-health.sh","probe:status":"bash ./probe-project-status.sh","probe:proxy":"bash ./probe-proxy.sh","probe:secrets":"bash ./deploy/probe-secrets.sh","probe:vs-hosted":"node ./probe-vs-hosted.mjs"},"dependencies":{"@modelcontextprotocol/sdk":"^1.12.0","express":"^4.21.2","graphql":"^16.9.0","graphql-request":"^7.1.2","zod":"^3.23.8"},"devDependencies":{"@types/express":"^4.17.21","@types/node":"^22.0.0","typescript":"^5.6.0","vitest":"^3.0.0"},"_id":"linear-mcp-lean@0.1.0","gitHead":"985b672c6d197edf54fca533801f83e507e6d3fb","_nodeVersion":"22.20.0","_npmVersion":"10.9.3","dist":{"integrity":"sha512-u85VoHEFKTcrc0Uk8whCBgt7LOD2DOYoILGS7Sa/4IIEF8QZX0vXpBkRXN7Mb9EeYKliBK3oLWGQJpkuWzPG2w==","shasum":"41386b9f6fb0b560cbc87c5c35894621f8881d0b","tarball":"https://registry.npmjs.org/linear-mcp-lean/-/linear-mcp-lean-0.1.0.tgz","fileCount":11,"unpackedSize":115090,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDKSfYfB/lZL8y0B7gXMSKqS/sByvgTzdblCiKnecpEHgIhAMhzKa87vCj6EzNUq3V73wkrnDgt0du2vWtOgGn4TQEg"}]},"_npmUser":{"name":"wiklob","email":"engined-5-pacers@icloud.com"},"directories":{},"maintainers":[{"name":"wiklob","email":"engined-5-pacers@icloud.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/linear-mcp-lean_0.1.0_1783447880764_0.36083645407506526"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-07T18:11:20.717Z","0.1.0":"2026-07-07T18:11:20.922Z","modified":"2026-07-07T18:11:21.110Z"},"maintainers":[{"name":"wiklob","email":"engined-5-pacers@icloud.com"}],"description":"Self-hosted MCP server wrapping Linear's GraphQL API with response-shape control — lean reads, minimal write acks, drop-in tool names.","homepage":"https://github.com/wiklob/linear-mcp-lean#readme","keywords":["mcp","mcp-server","model-context-protocol","linear","llm","agent"],"repository":{"type":"git","url":"git+https://github.com/wiklob/linear-mcp-lean.git"},"bugs":{"url":"https://github.com/wiklob/linear-mcp-lean/issues"},"license":"MIT","readme":"# linear-mcp-lean\n\n[![CI](https://github.com/wiklob/linear-mcp-lean/actions/workflows/ci.yml/badge.svg)](https://github.com/wiklob/linear-mcp-lean/actions/workflows/ci.yml)\n[![savings re-measured weekly](https://github.com/wiklob/linear-mcp-lean/actions/workflows/probe-vs-hosted.yml/badge.svg)](https://github.com/wiklob/linear-mcp-lean/actions/workflows/probe-vs-hosted.yml)\n\nA self-hosted [MCP](https://modelcontextprotocol.io) server for [Linear](https://linear.app) 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.\n\nBuilt 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.\n\n## In plain words\n\n- 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.**\n- 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.\n- Fewer bytes returned = fewer tokens your model reads = **cheaper, faster agent sessions** — the savings compound over hundreds of calls.\n- 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.**\n- 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**.\n- It authenticates with a plain Linear API key — no OAuth dance — so it works headless: CI, cron, background agents, multiple machines sharing one deploy.\n\n## Measured savings vs the hosted MCP\n\n<!-- savings:begin -->\n_Not yet measured — the weekly [probe-vs-hosted workflow](.github/workflows/probe-vs-hosted.yml) fills this in automatically once the repo's deploy secrets are configured. To measure by hand against your own deploy: `npm run probe:vs-hosted` (env in the file header)._\n<!-- savings:end -->\n\n## How it compares\n\n- **vs Linear's hosted MCP** ([mcp.linear.app](https://linear.app/docs/mcp)) — 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.\n- **vs community SDK wrappers** ([cline/linear-mcp](https://github.com/cline/linear-mcp), [tacticlaunch/mcp-linear](https://github.com/tacticlaunch/mcp-linear), [dvcrn/mcp-server-linear](https://github.com/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.\n- **vs [linear-toon-mcp](https://github.com/hoblin/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`.\n\n## How it works\n\n```\nMCP client ──stdio (npx linear-mcp-lean)──────────────▶ this server\n        or ──POST /mcp (Bearer MCP_BEARER_TOKEN)──────▶     │\n                                                            │\n                               hand-written minimal GraphQL ├──▶ api.linear.app/graphql   (LINEAR_API_KEY)\n                               verbatim proxy (5 tools)     └──▶ mcp.linear.app/mcp       (LINEAR_API_KEY)\n```\n\n- **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.\n\n- **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}`).\n- **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.\n- **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.\n- **`linear_graphql` escape hatch** — run an arbitrary GraphQL document and get the raw, untrimmed result, for the rare need the lean defaults don't cover.\n- **Byte-savings observability** — every call appends one JSONL record (upstream bytes vs bytes returned); `GET /stats` aggregates per-tool trim ratios from real traffic.\n\n### Stack\n\n`@modelcontextprotocol/sdk` (`McpServer` + stateless `StreamableHTTPServerTransport`) behind Express `POST /mcp`; `graphql-request` for the hand-written queries; zero database.\n\n## Quick start\n\nRequires Node 20+ and a [Linear Personal API key](https://linear.app/settings/account/security) (Settings → Security & access → Personal API keys).\n\n### Local (stdio) — no server to run\n\n```bash\n# with LINEAR_API_KEY exported in your shell:\nclaude mcp add linear -e LINEAR_API_KEY=${LINEAR_API_KEY} -- npx -y linear-mcp-lean\n```\n\nor in `.mcp.json` / `~/.claude.json` `mcpServers` (any MCP client with stdio support):\n\n```json\n{\n  \"mcpServers\": {\n    \"linear\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"linear-mcp-lean\"],\n      \"env\": { \"LINEAR_API_KEY\": \"${LINEAR_API_KEY}\" }\n    }\n  }\n}\n```\n\n(`${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.\n\n### Hosted (HTTP) — one deploy shared across machines and agents\n\n```bash\nnpm install\ncp .env.example .env    # fill in MCP_BEARER_TOKEN + LINEAR_API_KEY\nnpm run build\nnpm start               # listens on :$PORT (default 8080), MCP at POST /mcp\n```\n\n- `MCP_BEARER_TOKEN` — inbound auth: the token your MCP clients must send. Generate one: `openssl rand -hex 32`.\n- `LINEAR_API_KEY` — outbound auth: the same Personal API key as above.\n\nSmoke test:\n\n```bash\nsource .env\ncurl -s http://localhost:8080/health     # {\"ok\":true} — liveness, no Linear call\ncurl -s -H \"Authorization: Bearer $MCP_BEARER_TOKEN\" \\\n  http://localhost:8080/ready            # proves the LINEAR_API_KEY actually reaches Linear\ncurl -s -X POST http://localhost:8080/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H \"Authorization: Bearer $MCP_BEARER_TOKEN\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_issue\",\"arguments\":{\"id\":\"ENG-123\"}}}'\n```\n\n## Connect an MCP client\n\nRegister 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](#local-stdio--no-server-to-run); connecting to a hosted deploy:\n\n**Claude Code** (CLI):\n\n```bash\nclaude mcp add --transport http linear https://linear-mcp.example.com/mcp \\\n  --header \"Authorization: Bearer ${MCP_BEARER_TOKEN}\"\n```\n\nor in `.mcp.json` / `~/.claude.json` `mcpServers`:\n\n```json\n{\n  \"mcpServers\": {\n    \"linear\": {\n      \"type\": \"http\",\n      \"url\": \"https://linear-mcp.example.com/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer ${MCP_BEARER_TOKEN}\" }\n    }\n  }\n}\n```\n\n(`${MCP_BEARER_TOKEN}` is expanded from the client's environment at session start.)\n\nFor local experimentation, point at `http://localhost:8080/mcp` instead.\n\n## Tools\n\nSame names and semantics as the hosted Linear MCP (36 tools total):\n\n| Group | Tools |\n|-------|-------|\n| Issues | `get_issue`, `list_issues`, `save_issue`, `list_comments`, `save_comment` |\n| Projects | `get_project`, `list_projects`, `save_project`, `list_milestones`, `get_milestone`, `save_milestone` |\n| Teams & users | `get_team`, `list_teams`, `get_user`, `list_users` |\n| Labels & states | `list_issue_labels`, `list_project_labels`, `create_issue_label`, `list_issue_statuses`, `get_issue_status`, `list_cycles` |\n| Documents | `get_document`, `list_documents`, `save_document` |\n| Attachments | `get_attachment`, `create_attachment`, `prepare_attachment_upload`, `create_attachment_from_upload` |\n| Status updates | `get_status_updates`, `save_status_update` |\n| Proxied to hosted MCP | `search_documentation`, `extract_images`, `get_diff`, `get_diff_threads`, `list_diffs` |\n| Escape hatch | `linear_graphql` |\n\n## Field contract — lean default, broaden on demand\n\nReads are **lean by default, broaden on demand**. The full per-tool field map lives in [`FIELDS.md`](./FIELDS.md); the contract in brief:\n\n- **`full: true`** (opt-in on `get_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.\n\n- **`list_issues` returns a pagination envelope** matching the hosted MCP shape:\n  ```json\n  { \"issues\": [ … ], \"hasNextPage\": true, \"cursor\": \"<opaque token>\" }\n  ```\n  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).\n\n- **`linear_graphql({query, variables})`** — arbitrary GraphQL against `https://api.linear.app/graphql` via the same server-side client, raw untrimmed result. Bearer-gated like every tool; errors surface, never swallowed.\n  ```jsonc\n  // request\n  { \"name\": \"linear_graphql\", \"arguments\": {\n      \"query\": \"query($id:String!){ issue(id:$id){ identifier subscribers{ nodes{ name } } } }\",\n      \"variables\": { \"id\": \"ENG-123\" } } }\n  ```\n\n## Endpoints\n\n| Endpoint | Auth | Purpose |\n|----------|------|---------|\n| `POST /mcp` | Bearer | The MCP endpoint (stateless Streamable HTTP) |\n| `GET /health` | none | Liveness — process up, deliberately no Linear call |\n| `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 |\n| `GET /stats` | Bearer | Per-tool + overall upstream/downstream byte totals and trim ratios, plus byte-log write-health |\n\n## Tests and verification probes\n\nUnit 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):\n\n```bash\nnpm test\n```\n\nLive invariants are covered by encoded, runnable probes:\n\n```bash\nnpm run build\nnpm run probe:auth       # missing/invalid bearer -> 401; valid -> non-401 (key-free)\nnpm run probe:tools      # tools/list serves every promised tool name (key-free)\nnpm run probe:bytelog    # dead byte-log sink is distinguishable from idle on /stats (key-free)\nnpm run probe:secrets    # no secret tracked in the repo (offline)\nnpm run probe:status     # no deprecated GraphQL field selected (needs LINEAR_API_KEY)\nnpm run probe:proxy      # hosted MCP accepts the PAK bearer (needs LINEAR_API_KEY)\nnpm run probe:vs-hosted  # byte-savings comparison vs the hosted MCP (needs a deploy; see file header)\n```\n\n[CI](.github/workflows/ci.yml) 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](.github/workflows/probe-vs-hosted.yml) re-measures the savings table above weekly.\n\n## Deploy\n\n`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.\n\n## Security notes\n\n- **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.\n- 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.\n- `/ready` and `/stats` are bearer-gated too — they expose the viewer id and traffic shape.\n- 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.\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n","readmeFilename":"README.md","_rev":"1-16c20c38837d52b8bd2d097532d81432"}