Linear MCP Server
by samcfinan
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { LinearClient, LinearError } from "@linear/sdk"
import { z } from "zod"
// Initialize Linear client
const linearClient = new LinearClient({
apiKey: process.env.LINEAR_API_KEY ?? "",
})
if (!process.env.LINEAR_API_KEY) {
throw new Error("LINEAR_API_KEY is not set")
}
// Get default team ID (required for creating issues and projects)
let defaultTeamId: string
const initializeTeam = async () => {
const teams = await linearClient.teams()
const firstTeam = teams.nodes[0]
if (!firstTeam) {
throw new Error("No teams found in Linear workspace")
}
defaultTeamId = firstTeam.id
}
// Initialize team ID
initializeTeam().catch(console.error)
// Default prompt definition
const defaultPrompt = {
name: "default",
description: "Default prompt for Linear MCP Server",
messages: [
{
role: "system",
content: {
type: "text",
text: "You are a Linear assistant that helps manage issues and projects. For issue queries, use the search-issues tool directly with appropriate filters like 'assignee:@me' and 'priority:high'.",
},
},
],
}
// Create an MCP server
const server = new McpServer({
name: "linear",
version: "1.0.0",
description: "Linear MCP Server for accessing Linear resources",
capabilities: {
prompts: {
default: defaultPrompt,
},
resources: {
templates: true,
read: true,
},
tools: {
"create-project": {
description: "Create a new Linear project",
},
"view-project": {
description: "View a Linear project by ID",
},
"update-project": {
description: "Update a Linear project",
},
"create-initiative": {
description: "Create a new Linear initiative",
},
"view-initiative": {
description: "View a Linear initiative by ID",
},
"update-initiative": {
description: "Update a Linear initiative",
},
"create-issue": {
description: "Create a new Linear issue",
},
"view-issue": {
description: "View a Linear issue by ID",
},
"update-issue": {
description: "Update a Linear issue",
},
"search-issues": {
description: "Search Linear issues",
},
"create-issue-relation": {
description:
"Create a relation between two issues (blocks, duplicates, etc.)",
},
"view-issue-relations": {
description: "View all relations for a given issue",
},
"delete-issue-relation": {
description: "Delete a relation between two issues",
},
"link-project-to-initiative": {
description: "Link a project to an initiative",
},
"view-initiative-projects": {
description: "View projects associated with an initiative",
},
"unlink-project-from-initiative": {
description: "Unlink a project from an initiative",
},
"list-initiatives": {
description: "List all initiatives in the workspace",
},
"list-projects": {
description: "List all projects in the workspace",
},
},
},
})
// Helper function to handle errors consistently
const handleError = (error: unknown, context: string) => {
if (error instanceof LinearError) {
return {
content: [
{
type: "text" as const,
text: `Linear API Error: ${error.message}`,
},
],
isError: true,
}
}
return {
content: [
{
type: "text" as const,
text: `Error ${context}: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
// Project management tools
server.tool(
"create-project",
{
name: z.string(),
description: z.string().optional(),
state: z
.enum(["planned", "started", "paused", "completed", "canceled"])
.optional(),
},
async ({ name, description, state }) => {
try {
const project = await linearClient.createProject({
name,
description,
teamIds: [defaultTeamId],
state: state as any,
})
const createdProject = await project.project
if (!createdProject) {
throw new Error("Failed to create project")
}
return {
content: [
{
type: "text",
text: `Project created successfully. ID: ${createdProject.id}`,
},
],
}
} catch (error: unknown) {
return handleError(error, "creating project")
}
},
)
server.tool("view-project", { id: z.string() }, async ({ id }) => {
try {
const project = await linearClient.project(id)
return {
content: [
{
type: "text",
text: JSON.stringify(project, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "viewing project")
}
})
server.tool(
"update-project",
{
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
content: z.string().optional(),
priority: z.number().min(0).max(4).optional(),
state: z
.enum(["planned", "started", "paused", "completed", "canceled"])
.optional(),
},
async ({ id, name, description, content, priority }) => {
try {
await linearClient.updateProject(id, {
name,
description,
content,
priority,
})
return {
content: [
{
type: "text",
text: "Project updated successfully",
},
],
}
} catch (error: unknown) {
return handleError(error, "updating project")
}
},
)
// Initiative management tools
server.tool(
"create-initiative",
{
name: z.string(),
description: z.string().optional(),
},
async ({ name, description }) => {
try {
const initiative = await linearClient.createInitiative({
name,
description,
})
const createdInitiative = await initiative.initiative
if (!createdInitiative) {
throw new Error("Failed to create initiative")
}
return {
content: [
{
type: "text",
text: `Initiative created successfully. ID: ${createdInitiative.id}`,
},
],
}
} catch (error: unknown) {
return handleError(error, "creating initiative")
}
},
)
server.tool("view-initiative", { id: z.string() }, async ({ id }) => {
try {
const initiative = await linearClient.initiative(id)
return {
content: [
{
type: "text",
text: JSON.stringify(initiative, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "viewing initiative")
}
})
server.tool(
"update-initiative",
{
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
},
async ({ id, name, description }) => {
try {
const initiative = await linearClient.initiative(id)
await initiative.update({ name, description })
return {
content: [
{
type: "text",
text: "Initiative updated successfully",
},
],
}
} catch (error: unknown) {
return handleError(error, "updating initiative")
}
},
)
server.tool(
"list-initiatives",
{
includeArchived: z.boolean().optional(),
},
async ({ includeArchived }) => {
try {
const initiatives = await linearClient.initiatives({
includeArchived,
})
// Format the initiatives to show relevant information
const formattedInitiatives = initiatives.nodes.map(initiative => ({
id: initiative.id,
name: initiative.name,
description: initiative.description,
status: initiative.status,
health: initiative.health,
targetDate: initiative.targetDate,
}))
return {
content: [
{
type: "text",
text: JSON.stringify(formattedInitiatives, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "listing initiatives")
}
},
)
// Issue management tools
server.tool(
"create-issue",
{
title: z.string(),
description: z.string().optional(),
priority: z.number().min(0).max(4).optional(),
projectId: z.string().optional(),
},
async ({ title, description, priority, projectId }) => {
try {
const issue = await linearClient.createIssue({
title,
description,
priority,
projectId,
teamId: defaultTeamId,
})
const createdIssue = await issue.issue
if (!createdIssue) {
throw new Error("Failed to create issue")
}
return {
content: [
{
type: "text",
text: `Issue created successfully. ID: ${createdIssue.id}`,
},
],
}
} catch (error: unknown) {
return handleError(error, "creating issue")
}
},
)
server.tool("view-issue", { id: z.string() }, async ({ id }) => {
try {
const issue = await linearClient.issue(id)
return {
content: [
{
type: "text",
text: JSON.stringify(issue, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "viewing issue")
}
})
server.tool(
"update-issue",
{
id: z.string(),
title: z.string().optional(),
description: z.string().optional(),
priority: z.number().min(0).max(4).optional(),
projectId: z.string().optional(),
},
async ({ id, title, description, priority, projectId }) => {
try {
const issue = await linearClient.issue(id)
await issue.update({ title, description, priority, projectId })
return {
content: [
{
type: "text",
text: "Issue updated successfully",
},
],
}
} catch (error: unknown) {
return handleError(error, "updating issue")
}
},
)
server.tool("search-issues", { query: z.string() }, async ({ query }) => {
try {
const issues = await linearClient.issues({
filter: {
or: [
{ title: { contains: query } },
{ description: { contains: query } },
],
},
})
const issuesList = issues.nodes
return {
content: [
{
type: "text",
text: JSON.stringify(issuesList, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "searching issues")
}
})
server.tool(
"create-issue-relation",
{
issueId: z.string(),
relatedIssueId: z.string(),
type: z.enum(["blocks", "duplicate", "related"]),
},
async ({ issueId, relatedIssueId, type }) => {
try {
const relation = await linearClient.createIssueRelation({
issueId,
relatedIssueId,
// Linear SDK uses an enum which is not exported
// @ts-ignore-next-line
type: type,
})
return {
content: [
{
type: "text",
text: "Issue relation created successfully",
},
],
}
} catch (error: unknown) {
return handleError(error, "creating issue relation")
}
},
)
server.tool(
"view-issue-relations",
{ issueId: z.string() },
async ({ issueId }) => {
try {
const issue = await linearClient.issue(issueId)
const relations = await issue.relations()
const inverseRelations = await issue.inverseRelations()
const formattedRelations = await Promise.all([
...relations.nodes.map(async (relation) => {
const relatedIssue = await relation.relatedIssue
if (!relatedIssue) {
return null
}
return {
type: relation.type,
relatedIssue: {
id: relatedIssue.id,
title: relatedIssue.title,
},
}
}),
...inverseRelations.nodes.map(async (relation) => {
const relatedIssue = await relation.issue
if (!relatedIssue) {
return null
}
return {
type: `inverse_${relation.type}`,
relatedIssue: {
id: relatedIssue.id,
title: relatedIssue.title,
},
}
}),
])
const validRelations = formattedRelations.filter(
(r): r is NonNullable<typeof r> => r !== null,
)
return {
content: [
{
type: "text",
text: JSON.stringify(validRelations, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "viewing issue relations")
}
},
)
server.tool(
"delete-issue-relation",
{
issueId: z.string(),
relatedIssueId: z.string(),
},
async ({ issueId, relatedIssueId }) => {
try {
// First, find the relation ID by looking up both direct and inverse relations
const issue = await linearClient.issue(issueId)
const relations = await issue.relations()
const inverseRelations = await issue.inverseRelations()
let relationToDelete: { id: string } | undefined
// Check direct relations
for (const relation of relations.nodes) {
const related = await relation.relatedIssue
if (related && related.id === relatedIssueId) {
relationToDelete = relation
break
}
}
// Check inverse relations if not found
if (!relationToDelete) {
for (const relation of inverseRelations.nodes) {
const related = await relation.issue
if (related && related.id === relatedIssueId) {
relationToDelete = relation
break
}
}
}
if (!relationToDelete) {
throw new Error("Relation not found between the specified issues")
}
await linearClient.deleteIssueRelation(relationToDelete.id)
return {
content: [
{
type: "text",
text: "Issue relation deleted successfully",
},
],
}
} catch (error: unknown) {
return handleError(error, "deleting issue relation")
}
},
)
// Project-Initiative relationship tools
server.tool(
"link-project-to-initiative",
{
projectId: z.string(),
initiativeId: z.string(),
},
async ({ projectId, initiativeId }) => {
try {
const initiative = await linearClient.initiative(initiativeId)
const projects = await initiative.projects()
const existingLink = projects.nodes.find(project => project.id === projectId)
if (existingLink) {
throw new Error("Project is already linked to this initiative")
}
await linearClient.createInitiativeToProject({
initiativeId,
projectId,
})
return {
content: [
{
type: "text",
text: "Project successfully linked to initiative",
},
],
}
} catch (error: unknown) {
return handleError(error, "linking project to initiative")
}
},
)
server.tool("view-initiative-projects", { initiativeId: z.string() }, async ({ initiativeId }) => {
try {
const initiative = await linearClient.initiative(initiativeId)
const projects = await initiative.projects()
return {
content: [
{
type: "text",
text: JSON.stringify(projects.nodes, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "viewing initiative projects")
}
})
server.tool(
"unlink-project-from-initiative",
{
projectId: z.string(),
initiativeId: z.string(),
},
async ({ projectId, initiativeId }) => {
try {
const initiative = await linearClient.initiative(initiativeId)
const projects = await initiative.projects()
const existingProject = projects.nodes.find(project => project.id === projectId)
if (!existingProject) {
throw new Error("Project is not linked to this initiative")
}
// Get all initiative-project links
const links = await linearClient.initiativeToProjects()
const linkToDelete = await Promise.all(
links.nodes.map(async link => {
const initiative = await link.initiative
const project = await link.project
if (!initiative || !project) return null
return initiative.id === initiativeId && project.id === projectId ? link : null
})
).then(results => results.find(result => result !== null))
if (!linkToDelete) {
throw new Error("Initiative-project link not found")
}
await linearClient.deleteInitiativeToProject(linkToDelete.id)
return {
content: [
{
type: "text",
text: "Project successfully unlinked from initiative",
},
],
}
} catch (error: unknown) {
return handleError(error, "unlinking project from initiative")
}
},
)
server.tool(
"list-projects",
{
includeArchived: z.boolean().optional(),
state: z
.enum(["planned", "started", "paused", "completed", "canceled"])
.optional(),
},
async ({ includeArchived, state }) => {
try {
const projects = await linearClient.projects({
includeArchived,
filter: state ? { state: { eq: state } } : undefined,
})
// Format the projects to show relevant information
const formattedProjects = await Promise.all(
projects.nodes.map(async project => {
const initiative = project.initiatives ? (await project.initiatives()).nodes[0] : null
return {
id: project.id,
name: project.name,
description: project.description,
state: project.state,
progress: project.progress,
startDate: project.startDate,
targetDate: project.targetDate,
initiativeId: initiative?.id,
initiativeName: initiative?.name,
}
})
)
return {
content: [
{
type: "text",
text: JSON.stringify(formattedProjects, null, 2),
},
],
}
} catch (error: unknown) {
return handleError(error, "listing projects")
}
},
)
// Start receiving messages on stdin and sending messages on stdout
const transport = new StdioServerTransport()
await server.connect(transport)