Linear MCP Server
by tiovikram
- src
#!/usr/bin/env node
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { config } from "dotenv";
// Load .env file from the project root
const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: join(__dirname, "..", ".env") });
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { Request } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { LinearClient } from "@linear/sdk";
const API_KEY = process.env.LINEAR_API_KEY || process.env.LINEARAPIKEY;
if (!API_KEY) {
console.error("Error: LINEAR_API_KEY environment variable is required");
console.error("");
console.error("To use this tool, run it with your Linear API key:");
console.error("LINEAR_API_KEY=your-api-key npx @ibraheem4/linear-mcp");
console.error("");
console.error("Or set it in your environment:");
console.error("export LINEAR_API_KEY=your-api-key");
console.error("npx @ibraheem4/linear-mcp");
process.exit(1);
}
const linearClient = new LinearClient({
apiKey: API_KEY,
});
const server = new Server(
{
name: "linear-mcp",
version: "37.0.0", // Match Linear SDK version
},
{
capabilities: {
tools: {
create_issue: true,
list_issues: true,
update_issue: true,
list_teams: true,
list_projects: true,
search_issues: true,
get_issue: true,
},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "create_issue",
description: "Create a new issue in Linear",
inputSchema: {
type: "object",
properties: {
title: {
type: "string",
description: "Issue title",
},
description: {
type: "string",
description: "Issue description (markdown supported)",
},
teamId: {
type: "string",
description: "Team ID",
},
assigneeId: {
type: "string",
description: "Assignee user ID (optional)",
},
priority: {
type: "number",
description: "Priority (0-4, optional)",
minimum: 0,
maximum: 4,
},
labels: {
type: "array",
items: {
type: "string",
},
description: "Label IDs to apply (optional)",
},
},
required: ["title", "teamId"],
},
},
{
name: "list_issues",
description: "List issues with optional filters",
inputSchema: {
type: "object",
properties: {
teamId: {
type: "string",
description: "Filter by team ID (optional)",
},
assigneeId: {
type: "string",
description: "Filter by assignee ID (optional)",
},
status: {
type: "string",
description: "Filter by status (optional)",
},
first: {
type: "number",
description: "Number of issues to return (default: 50)",
},
},
},
},
{
name: "update_issue",
description: "Update an existing issue",
inputSchema: {
type: "object",
properties: {
issueId: {
type: "string",
description: "Issue ID",
},
title: {
type: "string",
description: "New title (optional)",
},
description: {
type: "string",
description: "New description (optional)",
},
status: {
type: "string",
description: "New status (optional)",
},
assigneeId: {
type: "string",
description: "New assignee ID (optional)",
},
priority: {
type: "number",
description: "New priority (0-4, optional)",
minimum: 0,
maximum: 4,
},
},
required: ["issueId"],
},
},
{
name: "list_teams",
description: "List all teams in the workspace",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "list_projects",
description: "List all projects",
inputSchema: {
type: "object",
properties: {
teamId: {
type: "string",
description: "Filter by team ID (optional)",
},
first: {
type: "number",
description: "Number of projects to return (default: 50)",
},
},
},
},
{
name: "search_issues",
description: "Search for issues using a text query",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query text",
},
first: {
type: "number",
description: "Number of results to return (default: 50)",
},
},
required: ["query"],
},
},
{
name: "get_issue",
description: "Get detailed information about a specific issue",
inputSchema: {
type: "object",
properties: {
issueId: {
type: "string",
description: "Issue ID",
},
},
required: ["issueId"],
},
},
],
}));
type CreateIssueArgs = {
title: string;
description?: string;
teamId: string;
assigneeId?: string;
priority?: number;
labels?: string[];
};
type ListIssuesArgs = {
teamId?: string;
assigneeId?: string;
status?: string;
first?: number;
};
type UpdateIssueArgs = {
issueId: string;
title?: string;
description?: string;
status?: string;
assigneeId?: string;
priority?: number;
};
type ListProjectsArgs = {
teamId?: string;
first?: number;
};
type SearchIssuesArgs = {
query: string;
first?: number;
};
type GetIssueArgs = {
issueId: string;
};
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "create_issue": {
const args = request.params.arguments as unknown as CreateIssueArgs;
if (!args?.title || !args?.teamId) {
throw new Error("Title and teamId are required");
}
const issue = await linearClient.createIssue({
title: args.title,
description: args.description,
teamId: args.teamId,
assigneeId: args.assigneeId,
priority: args.priority,
labelIds: args.labels,
});
return {
content: [
{
type: "text",
text: JSON.stringify(issue, null, 2),
},
],
};
}
case "list_issues": {
const args = request.params.arguments as unknown as ListIssuesArgs;
const filter: Record<string, any> = {};
if (args?.teamId) filter.team = { id: { eq: args.teamId } };
if (args?.assigneeId) filter.assignee = { id: { eq: args.assigneeId } };
if (args?.status) filter.state = { name: { eq: args.status } };
const issues = await linearClient.issues({
first: args?.first ?? 50,
filter,
});
const formattedIssues = await Promise.all(
issues.nodes.map(async (issue) => {
const state = await issue.state;
const assignee = await issue.assignee;
return {
id: issue.id,
title: issue.title,
status: state ? await state.name : "Unknown",
assignee: assignee ? assignee.name : "Unassigned",
priority: issue.priority,
url: issue.url,
};
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(formattedIssues, null, 2),
},
],
};
}
case "update_issue": {
const args = request.params.arguments as unknown as UpdateIssueArgs;
if (!args?.issueId) {
throw new Error("Issue ID is required");
}
const issue = await linearClient.issue(args.issueId);
if (!issue) {
throw new Error(`Issue ${args.issueId} not found`);
}
const updatedIssue = await issue.update({
title: args.title,
description: args.description,
stateId: args.status,
assigneeId: args.assigneeId,
priority: args.priority,
});
return {
content: [
{
type: "text",
text: JSON.stringify(updatedIssue, null, 2),
},
],
};
}
case "list_teams": {
const query = await linearClient.teams();
const teams = await Promise.all(
(query as any).nodes.map(async (team: any) => ({
id: team.id,
name: team.name,
key: team.key,
description: team.description,
}))
);
return {
content: [
{
type: "text",
text: JSON.stringify(teams, null, 2),
},
],
};
}
case "list_projects": {
const args = request.params.arguments as unknown as ListProjectsArgs;
const filter: Record<string, any> = {};
if (args?.teamId) filter.team = { id: { eq: args.teamId } };
const query = await linearClient.projects({
first: args?.first ?? 50,
filter,
});
const projects = await Promise.all(
(query as any).nodes.map(async (project: any) => {
const teamsConnection = await project.teams;
const teams = teamsConnection ? (teamsConnection as any).nodes : [];
return {
id: project.id,
name: project.name,
description: project.description,
state: project.state,
teamIds: teams.map((team: any) => team.id),
};
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(projects, null, 2),
},
],
};
}
case "search_issues": {
const args = request.params.arguments as unknown as SearchIssuesArgs;
if (!args?.query) {
throw new Error("Search query is required");
}
const searchResults = await linearClient.searchIssues(args.query, {
first: args?.first ?? 50,
});
const formattedResults = await Promise.all(
searchResults.nodes.map(async (result) => {
const state = await result.state;
const assignee = await result.assignee;
return {
id: result.id,
title: result.title,
status: state ? await state.name : "Unknown",
assignee: assignee ? assignee.name : "Unassigned",
priority: result.priority,
url: result.url,
metadata: result.metadata,
};
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(formattedResults, null, 2),
},
],
};
}
case "get_issue": {
const args = request.params.arguments as unknown as GetIssueArgs;
if (!args?.issueId) {
throw new Error("Issue ID is required");
}
const issue = await linearClient.issue(args.issueId);
if (!issue) {
throw new Error(`Issue ${args.issueId} not found`);
}
try {
const [
state,
assignee,
creator,
team,
project,
parent,
cycle,
labels,
comments,
attachments,
] = await Promise.all([
issue.state,
issue.assignee,
issue.creator,
issue.team,
issue.project,
issue.parent,
issue.cycle,
issue.labels(),
issue.comments(),
issue.attachments(),
]);
const issueDetails: {
id: string;
identifier: string;
title: string;
description: string | undefined;
priority: number;
priorityLabel: string;
status: string;
url: string;
createdAt: Date;
updatedAt: Date;
startedAt: Date | null;
completedAt: Date | null;
canceledAt: Date | null;
dueDate: string | null;
assignee: { id: string; name: string; email: string } | null;
creator: { id: string; name: string; email: string } | null;
team: { id: string; name: string; key: string } | null;
project: { id: string; name: string; state: string } | null;
parent: { id: string; title: string; identifier: string } | null;
cycle: { id: string; name: string; number: number } | null;
labels: Array<{ id: string; name: string; color: string }>;
comments: Array<{ id: string; body: string; createdAt: Date }>;
attachments: Array<{ id: string; title: string; url: string }>;
embeddedImages: Array<{ url: string; analysis: string }>;
estimate: number | null;
customerTicketCount: number;
previousIdentifiers: string[];
branchName: string;
archivedAt: Date | null;
autoArchivedAt: Date | null;
autoClosedAt: Date | null;
trashed: boolean;
} = {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
priorityLabel: issue.priorityLabel,
status: state ? await state.name : "Unknown",
url: issue.url,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
startedAt: issue.startedAt || null,
completedAt: issue.completedAt || null,
canceledAt: issue.canceledAt || null,
dueDate: issue.dueDate,
assignee: assignee
? {
id: assignee.id,
name: assignee.name,
email: assignee.email,
}
: null,
creator: creator
? {
id: creator.id,
name: creator.name,
email: creator.email,
}
: null,
team: team
? {
id: team.id,
name: team.name,
key: team.key,
}
: null,
project: project
? {
id: project.id,
name: project.name,
state: project.state,
}
: null,
parent: parent
? {
id: parent.id,
title: parent.title,
identifier: parent.identifier,
}
: null,
cycle:
cycle && cycle.name
? {
id: cycle.id,
name: cycle.name,
number: cycle.number,
}
: null,
labels: await Promise.all(
labels.nodes.map(async (label: any) => ({
id: label.id,
name: label.name,
color: label.color,
}))
),
comments: await Promise.all(
comments.nodes.map(async (comment: any) => ({
id: comment.id,
body: comment.body,
createdAt: comment.createdAt,
}))
),
attachments: await Promise.all(
attachments.nodes.map(async (attachment: any) => ({
id: attachment.id,
title: attachment.title,
url: attachment.url,
}))
),
embeddedImages: [],
estimate: issue.estimate || null,
customerTicketCount: issue.customerTicketCount || 0,
previousIdentifiers: issue.previousIdentifiers || [],
branchName: issue.branchName || "",
archivedAt: issue.archivedAt || null,
autoArchivedAt: issue.autoArchivedAt || null,
autoClosedAt: issue.autoClosedAt || null,
trashed: issue.trashed || false,
};
// Extract embedded images from description
const imageMatches =
issue.description?.match(/!\[.*?\]\((.*?)\)/g) || [];
if (imageMatches.length > 0) {
issueDetails.embeddedImages = imageMatches.map((match) => {
const url = (match as string).match(/\((.*?)\)/)?.[1] || "";
return {
url,
analysis: "Image analysis would go here", // Replace with actual image analysis if available
};
});
}
// Add image analysis for attachments if they are images
issueDetails.attachments = await Promise.all(
attachments.nodes
.filter((attachment: any) =>
attachment.url.match(/\.(jpg|jpeg|png|gif|webp)$/i)
)
.map(async (attachment: any) => ({
id: attachment.id,
title: attachment.title,
url: attachment.url,
analysis: "Image analysis would go here", // Replace with actual image analysis if available
}))
);
return {
content: [
{
type: "text",
text: JSON.stringify(issueDetails, null, 2),
},
],
};
} catch (error: any) {
console.error("Error processing issue details:", error);
throw new Error(`Failed to process issue details: ${error.message}`);
}
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error: any) {
console.error("Linear API Error:", error);
return {
content: [
{
type: "text",
text: `Linear API error: ${error.message}`,
},
],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Linear MCP server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});