Guide · Productivity & Project Management Integrations

MCP Server Linear — GraphQL SDK, issue states, team scoping, webhooks

Three Linear behaviours trap MCP tool authors on first contact: workflow states are team-scoped, not global — there is no universal "In Progress" state ID; you must query team.states to get the state IDs for a specific team before creating or transitioning issues; the Linear TypeScript SDK wraps GraphQL, but raw mutations are required for operations the SDK doesn't expose (bulk label assignment, parent-child linking in a single mutation); and label names are mutable — only label IDs are stable — storing a label name to look up later silently breaks when the workspace renames it.

TL;DR

Create a module-level LinearClient with your API key. Before creating issues, query client.teams() and team.states() to resolve team and state IDs — never hardcode them. Use client.createIssue() for standard creation and fall back to client.rawRequest() for operations not in the SDK (bulk assignment, multi-label mutations). Store label and state IDs — not names — in your tool's persistent config. For webhooks, verify the Linear-Signature header with HMAC-SHA256 before processing the payload.

Client setup and team/state resolution

The Linear SDK reads your personal access token from an explicit parameter. Create the client once at module scope. The most common setup mistake is hardcoding a workflow state name like "In Progress" — state names vary by team and can be renamed. Resolve state IDs at startup by querying the team's actual state list.

import { LinearClient } from '@linear/sdk';
import { z } from 'zod';

// Module-level singleton — one HTTP connection pool shared across tool calls
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });

// Cache team and state IDs at startup — these don't change often
let teamCache: Map<string, string> = new Map();   // teamKey → teamId
let stateCache: Map<string, string> = new Map();  // `${teamId}:${stateName}` → stateId

async function resolveTeam(teamKey: string): Promise<{ teamId: string; states: Record<string, string> }> {
  const teams = await linear.teams({ filter: { key: { eq: teamKey } } });
  const team  = teams.nodes[0];
  if (!team) throw new Error(`Linear team not found: ${teamKey}`);

  // States are team-specific — "In Progress" in ENG may differ from "In Progress" in DESIGN
  const stateConn = await team.states();
  const states: Record<string, string> = {};
  for (const s of stateConn.nodes) {
    states[s.name] = s.id;
    stateCache.set(`${team.id}:${s.name}`, s.id);
  }

  teamCache.set(teamKey, team.id);
  return { teamId: team.id, states };
}

// MCP tool — create an issue in a specific Linear team
server.tool(
  'linear_create_issue',
  {
    team_key:    z.string().min(1).max(10).describe('Linear team key, e.g. "ENG"'),
    title:       z.string().min(1).max(512),
    description: z.string().max(10_000).optional(),
    priority:    z.number().int().min(0).max(4).optional().default(0),
    state_name:  z.string().optional().describe('Workflow state name, e.g. "Backlog", "In Progress"'),
    label_ids:   z.array(z.string()).optional().describe('Array of stable label IDs (not names)'),
    assignee_id: z.string().optional(),
  },
  async ({ team_key, title, description, priority, state_name, label_ids, assignee_id }) => {
    const { teamId, states } = await resolveTeam(team_key);

    // Resolve state ID from name — fall back to team default if state not found
    let stateId: string | undefined;
    if (state_name) {
      stateId = states[state_name];
      if (!stateId) {
        const available = Object.keys(states).join(', ');
        return {
          content: [{
            type: 'text',
            text: `State "${state_name}" not found in team ${team_key}. Available: ${available}`,
          }],
          isError: true,
        };
      }
    }

    const issue = await linear.createIssue({
      teamId,
      title,
      description,
      priority,
      stateId,
      labelIds: label_ids,
      assigneeId: assignee_id,
    });

    const created = await issue.issue;
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:         created?.id,
          identifier: created?.identifier,  // e.g. "ENG-142"
          url:        created?.url,
          title:      created?.title,
        }),
      }],
    };
  }
);

The identifier field on the created issue contains the human-readable key like ENG-142 — use this in tool responses so agents and users can reference the issue. The numeric id is the stable UUID used in subsequent API calls. Never use the identifier as an API key — it's presentational and can shift if issues are moved between teams.

Cursor pagination and issue search

Linear uses cursor-based pagination on all list endpoints. The pageInfo.hasNextPage flag and pageInfo.endCursor string drive iteration. The default page size is 50; the maximum is 250. Passing first: 250 with an after cursor iterates through large result sets efficiently.

// MCP tool — search issues with cursor pagination
server.tool(
  'linear_search_issues',
  {
    team_key:   z.string(),
    query:      z.string().optional().describe('Full-text search query'),
    state_name: z.string().optional(),
    assignee_id: z.string().optional(),
    limit:      z.number().int().min(1).max(250).default(50),
  },
  async ({ team_key, query, state_name, assignee_id, limit }) => {
    const { teamId, states } = await resolveTeam(team_key);

    const filter: Record<string, unknown> = {
      team: { id: { eq: teamId } },
    };

    if (state_name) {
      const stateId = states[state_name];
      if (!stateId) throw new Error(`Unknown state: ${state_name}`);
      filter.state = { id: { eq: stateId } };
    }

    if (assignee_id) {
      filter.assignee = { id: { eq: assignee_id } };
    }

    // Linear's issues() supports both filter and a separate 'filter' text-search param
    const issueConn = await linear.issues({
      first: limit,
      filter: filter as any,
      ...(query ? { filter: { ...filter as any, title: { containsIgnoreCase: query } } } : {}),
      orderBy: 'updatedAt',
    });

    const results = issueConn.nodes.map(i => ({
      id:         i.id,
      identifier: i.identifier,
      title:      i.title,
      priority:   i.priority,
      url:        i.url,
    }));

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          issues:      results,
          hasNextPage: issueConn.pageInfo.hasNextPage,
          endCursor:   issueConn.pageInfo.endCursor,
          totalCount:  issueConn.nodes.length,
        }),
      }],
    };
  }
);

// MCP tool — fetch next page using cursor from previous call
server.tool(
  'linear_issues_next_page',
  {
    team_key:   z.string(),
    after:      z.string().describe('endCursor from previous linear_search_issues call'),
    limit:      z.number().int().min(1).max(250).default(50),
  },
  async ({ team_key, after, limit }) => {
    const { teamId } = await resolveTeam(team_key);

    const issueConn = await linear.issues({
      first:  limit,
      after,
      filter: { team: { id: { eq: teamId } } } as any,
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          issues:      issueConn.nodes.map(i => ({ id: i.id, identifier: i.identifier, title: i.title })),
          hasNextPage: issueConn.pageInfo.hasNextPage,
          endCursor:   issueConn.pageInfo.endCursor,
        }),
      }],
    };
  }
);

Labels: IDs are stable, names are not

Linear labels can be renamed by workspace admins at any time. A tool that looks up a label by name to get its ID will silently break after a rename. Always resolve label IDs once (at startup or on first use), persist the IDs, and include a linear_list_labels tool so agents can discover the current name-to-ID mapping.

// MCP tool — list available labels for a team (so agents can resolve names → IDs)
server.tool(
  'linear_list_labels',
  { team_key: z.string() },
  async ({ team_key }) => {
    const { teamId } = await resolveTeam(team_key);

    // Labels can be team-specific or organization-wide
    // team.labels() returns only labels scoped to this team
    const teams = await linear.teams({ filter: { id: { eq: teamId } } });
    const team  = teams.nodes[0];
    if (!team) throw new Error(`Team not found: ${team_key}`);

    const labelConn = await team.labels();
    const labels = labelConn.nodes.map(l => ({
      id:    l.id,      // stable — use this in tool calls
      name:  l.name,    // mutable — display only
      color: l.color,
    }));

    return { content: [{ type: 'text', text: JSON.stringify(labels) }] };
  }
);

// Raw GraphQL mutation — bulk-assign labels to multiple issues in one request
// The SDK's updateIssue() only accepts a single issue at a time
server.tool(
  'linear_bulk_add_label',
  {
    issue_ids: z.array(z.string()).min(1).max(25),
    label_id:  z.string(),
  },
  async ({ issue_ids, label_id }) => {
    // Linear doesn't have a native bulk mutation — send parallel individual mutations
    const mutations = issue_ids.map((id, i) => `
      update${i}: updateIssue(id: "${id}", input: { labelIds: ["${label_id}"] }) {
        success
        issue { id identifier }
      }
    `).join('\n');

    const result = await linear.rawRequest(`mutation BulkAddLabel { ${mutations} }`);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  }
);

linear.rawRequest() sends a raw GraphQL document and returns the response data directly. Use it for operations that combine multiple mutations in one network round-trip — Linear's API processes aliased mutation fields in a single request, which is significantly faster than sequential updateIssue() calls when processing batches of 10–25 issues.

Webhook signature verification

Linear sends a Linear-Signature header with each webhook containing an HMAC-SHA256 hex digest computed from the raw request body using your webhook secret. Verify this before processing — unsigned webhooks are a source of replay and injection attacks on issue-tracking integrations.

import crypto from 'crypto';
import type { IncomingMessage, ServerResponse } from 'http';

const WEBHOOK_SECRET = process.env.LINEAR_WEBHOOK_SECRET!;

async function rawBody(req: IncomingMessage): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', c => chunks.push(c));
    req.on('end',  () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
}

async function linearWebhookHandler(req: IncomingMessage, res: ServerResponse) {
  const body = await rawBody(req);
  const sig  = req.headers['linear-signature'] as string | undefined;

  if (!sig) {
    res.writeHead(401).end('Missing Linear-Signature');
    return;
  }

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

  // Constant-time comparison prevents timing attacks
  if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) {
    res.writeHead(401).end('Invalid signature');
    return;
  }

  const event = JSON.parse(body.toString());

  // Linear webhook payload fields:
  // event.type     — "Issue" | "Comment" | "Cycle" | "Project" | etc.
  // event.action   — "create" | "update" | "remove"
  // event.data     — the entity that changed
  // event.updatedFrom — previous values (for "update" actions)

  switch (`${event.type}:${event.action}`) {
    case 'Issue:create':
      await handleNewIssue(event.data);
      break;
    case 'Issue:update':
      await handleIssueUpdate(event.data, event.updatedFrom);
      break;
    case 'Issue:remove':
      await handleIssueRemoved(event.data);
      break;
  }

  res.writeHead(200).end('ok');
}

async function handleIssueUpdate(
  current:  { id: string; stateId: string; assigneeId?: string },
  previous: { stateId?: string; assigneeId?: string }
) {
  // updatedFrom only includes fields that changed — check existence before reading
  if (previous.stateId && previous.stateId !== current.stateId) {
    console.log(`Issue ${current.id}: state changed from ${previous.stateId} → ${current.stateId}`);
  }
}

Register webhooks via the Linear workspace settings under Settings → API → Webhooks. Each webhook targets a specific team and resource type. The webhook URL must be publicly reachable — use a tunnel (ngrok, Cloudflare Tunnel) during local development. Linear retries failed deliveries (non-200 responses) with exponential backoff up to 10 attempts over 24 hours.