@sylphx/linear-mcp
Version:
A Model Context Protocol (MCP) server for interacting with Linear issues, projects, teams, and more
1,422 lines (1,391 loc) • 71.3 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { McpError as McpError19, ErrorCode as ErrorCode19 } from "@modelcontextprotocol/sdk/types.js";
// src/utils/linear-client.ts
import { LinearClient } from "@linear/sdk";
var LinearClientManager = class _LinearClientManager {
static instance;
client = null;
constructor() {
}
static getInstance() {
if (!_LinearClientManager.instance) {
_LinearClientManager.instance = new _LinearClientManager();
}
return _LinearClientManager.instance;
}
initialize(apiKey) {
if (!this.client) {
this.client = new LinearClient({ apiKey });
}
}
getClient() {
if (!this.client) {
throw new Error("Linear client not initialized. Call initialize() first.");
}
return this.client;
}
};
var getLinearClient = () => {
return LinearClientManager.getInstance().getClient();
};
// src/tools/shared/tool-definition.ts
var defineTool = (tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
handler: tool.handler
});
// src/tools/issue-statuses/shared.ts
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
var IssueStatusListSchema = {
teamId: z.string().describe("The team UUID")
};
var IssueStatusQuerySchema = {
query: z.string().describe("The UUID or name of the issue status to retrieve"),
teamId: z.string().describe("The team UUID")
};
async function validateTeamOrThrow(teamId) {
const linearClient = getLinearClient();
const team = await linearClient.team(teamId);
if (!team) {
let availableTeamsMessage = "";
try {
const allTeams = await linearClient.teams();
if (allTeams.nodes.length > 0) {
const teamList = allTeams.nodes.map((t) => ({
id: t.id,
name: t.name
}));
availableTeamsMessage = ` Valid teams are: ${JSON.stringify(teamList, null, 2)}`;
} else {
availableTeamsMessage = " No teams available to list.";
}
} catch (_listError) {
availableTeamsMessage = " (Could not fetch available teams for context.)";
}
throw new McpError(
ErrorCode.InvalidParams,
`Team with ID '${teamId}' not found.${availableTeamsMessage}`
);
}
return team;
}
function throwInternalError(message, error) {
const err = error;
throw new McpError(ErrorCode.InternalError, `${message}: ${err.message || "Unknown error"}`);
}
// src/tools/issue-statuses/get_issue_status.ts
var getIssueStatusTool = defineTool({
name: "get_issue_status",
description: "Retrieve details of a specific issue status in Linear by name or ID",
inputSchema: IssueStatusQuerySchema,
handler: async ({ query, teamId }) => {
try {
const team = await validateTeamOrThrow(teamId);
const states = await team.states();
let state = states.nodes.find((s) => s.id === query);
if (!state) {
state = states.nodes.find((s) => s.name.toLowerCase() === query.toLowerCase());
}
if (state) {
return {
content: [
{
type: "text",
text: JSON.stringify({
id: state.id,
name: state.name,
color: state.color,
type: state.type,
description: state.description,
position: state.position
})
}
]
};
}
const validStatuses = states.nodes.map((s) => ({
id: s.id,
name: s.name,
type: s.type
}));
throw new Error(
`Issue status with query "${query}" not found in team '${team.name}' (${teamId}). Valid statuses for this team are: ${JSON.stringify(validStatuses, null, 2)}`
);
} catch (error) {
throwInternalError("Failed to get issue status", error);
}
}
});
// src/tools/issue-statuses/list_issue_statuses.ts
var listIssueStatusesTool = defineTool({
name: "list_issue_statuses",
description: "List available issues statuses in a Linear team",
inputSchema: IssueStatusListSchema,
handler: async ({ teamId }) => {
try {
const team = await validateTeamOrThrow(teamId);
const states = await team.states();
return {
content: [
{
type: "text",
text: JSON.stringify(
states.nodes.map((state) => ({
id: state.id,
name: state.name,
color: state.color,
type: state.type,
description: state.description,
position: state.position
}))
)
}
]
};
} catch (error) {
throwInternalError("Failed to list issue statuses", error);
}
}
});
// src/tools/issue-statuses/index.ts
var issueStatusTools = [listIssueStatusesTool, getIssueStatusTool];
// src/tools/issues/shared.ts
import {
LinearDocument
} from "@linear/sdk";
import { ErrorCode as ErrorCode2, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
import { z as z2 } from "zod";
var IdSchema = {
id: z2.string().describe("The issue ID")
};
var PaginationSchema = {
limit: z2.number().default(50).describe("The number of items to return"),
before: z2.string().optional().describe("A UUID to end at"),
after: z2.string().optional().describe("A UUID to start from"),
orderBy: z2.enum(["createdAt", "updatedAt"]).default("updatedAt")
};
var IssueFilterSchema = {
query: z2.string().optional().describe("An optional search query"),
teamId: z2.string().optional().describe("The team UUID"),
stateId: z2.string().optional().describe("The state UUID"),
assigneeId: z2.string().optional().describe("The assignee UUID"),
projectMilestoneId: z2.string().uuid("Invalid project milestone ID").optional().describe("The project milestone ID to filter by"),
includeArchived: z2.boolean().default(true).describe("Whether to include archived issues"),
limit: z2.number().default(50).describe("The number of issues to return"),
projectId: z2.string().optional().describe("The project ID to filter by")
};
var IssueCreateSchema = {
title: z2.string().describe("The issue title"),
description: z2.string().optional().describe("The issue description as Markdown"),
teamId: z2.string().describe("The team UUID"),
priority: z2.number().optional().describe("The issue priority. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low."),
projectId: z2.string().optional().describe("The project ID to add the issue to"),
stateId: z2.string().optional().describe("The issue state ID"),
assigneeId: z2.string().optional().describe("The assignee ID"),
labelIds: z2.array(z2.string()).optional().describe("Array of label IDs to set on the issue"),
dueDate: z2.string().optional().describe("The due date for the issue in ISO format"),
projectMilestoneId: z2.string().uuid("Invalid project milestone ID").optional().describe("The project milestone ID to associate the issue with")
};
var IssueUpdateSchema = {
id: z2.string().describe("The issue ID"),
title: z2.string().optional().describe("The issue title"),
description: z2.string().optional().describe("The issue description as Markdown"),
priority: z2.number().optional().describe("The issue priority. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low."),
projectId: z2.string().optional().describe("The project ID to add the issue to"),
stateId: z2.string().optional().describe("The issue state ID"),
assigneeId: z2.string().optional().describe("The assignee ID"),
labelIds: z2.array(z2.string()).optional().describe("Array of label IDs to set on the issue"),
dueDate: z2.string().optional().describe("The due date for the issue in ISO format"),
projectMilestoneId: z2.string().uuid("Invalid project milestone ID").nullable().optional().describe("The project milestone ID to associate the issue with (null to remove)")
};
var CommentCreateSchema = {
issueId: z2.string().describe("The issue ID"),
body: z2.string().describe("The content of the comment as Markdown")
};
async function getAvailableTeamsJson(linearClient) {
try {
const teams = await linearClient.teams();
if (!teams.nodes || teams.nodes.length === 0) return "[]";
return JSON.stringify(
teams.nodes.map((team) => ({ id: team.id, name: team.name, key: team.key })),
null,
2
);
} catch (e) {
return `"(Could not fetch available teams as JSON: ${e.message})"`;
}
}
async function getAvailableProjectsJson(linearClient) {
try {
const projects = await linearClient.projects();
if (!projects.nodes || projects.nodes.length === 0) return "[]";
return JSON.stringify(
projects.nodes.map((project) => ({ id: project.id, name: project.name })),
null,
2
);
} catch (e) {
return `"(Could not fetch available projects as JSON: ${e.message})"`;
}
}
async function getAvailableStatesJson(linearClient, teamId) {
if (!teamId) return `"(Cannot fetch states without a valid teamId.)"`;
try {
const team = await linearClient.team(teamId);
if (!team)
return `"(Could not fetch states: Team with ID '${teamId}' not found. Valid teams are: ${await getAvailableTeamsJson(linearClient)})"`;
const states = await team.states();
if (!states.nodes || states.nodes.length === 0) return "[]";
return JSON.stringify(
states.nodes.map((state) => ({ id: state.id, name: state.name, type: state.type })),
null,
2
);
} catch (e) {
return `"(Could not fetch states for team ${teamId} as JSON: ${e.message})"`;
}
}
async function getAvailableAssigneesJson(linearClient) {
try {
const users = await linearClient.users({ filter: { active: { eq: true } } });
if (!users.nodes || users.nodes.length === 0) return "[]";
return JSON.stringify(
users.nodes.map((user) => ({ id: user.id, name: user.displayName, email: user.email })),
null,
2
);
} catch (e) {
return `"(Could not fetch available assignees as JSON: ${e.message})"`;
}
}
async function getAvailableLabelsJson(linearClient, teamId) {
if (!teamId) return `"(Cannot fetch labels without a valid teamId.)"`;
try {
const team = await linearClient.team(teamId);
if (!team)
return `"(Could not fetch labels: Team with ID '${teamId}' not found. Valid teams are: ${await getAvailableTeamsJson(linearClient)})"`;
const labels = await team.labels();
if (!labels.nodes || labels.nodes.length === 0) return "[]";
return JSON.stringify(
labels.nodes.map((label) => ({ id: label.id, name: label.name, color: label.color })),
null,
2
);
} catch (e) {
return `"(Could not fetch labels for team ${teamId} as JSON: ${e.message})"`;
}
}
async function getAvailableProjectMilestonesJson(linearClient, projectId) {
try {
if (projectId && projectId !== "any" && projectId.trim() !== "") {
const project = await linearClient.project(projectId);
if (!project)
return `"(Could not fetch milestones: Project with ID '${projectId}' not found. Valid projects are: ${await getAvailableProjectsJson(linearClient)})"`;
const milestones = await project.projectMilestones();
if (!milestones.nodes || milestones.nodes.length === 0) return "[]";
return JSON.stringify(
milestones.nodes.map((m) => ({ id: m.id, name: m.name })),
null,
2
);
}
return `"(Project milestones are specific to a project. Please provide a valid projectId. Available projects: ${await getAvailableProjectsJson(linearClient)})"`;
} catch (e) {
const error = e;
if (projectId && projectId !== "any" && error.message.toLowerCase().includes("not found")) {
return `"(Could not fetch milestones for project '${projectId}': ${error.message}. Valid projects are: ${await getAvailableProjectsJson(linearClient)})"`;
}
return `"(Could not fetch project milestones for project ${projectId} as JSON: ${error.message})"`;
}
}
async function mapIssueToDetails(issue, includeAttachments = false) {
const [state, assignee, team, project, projectMilestone, labelsResult, attachmentsResult] = await Promise.all([
issue.state,
issue.assignee,
issue.team,
issue.project,
issue.projectMilestone,
issue.labels(),
includeAttachments ? issue.attachments() : Promise.resolve(null)
]);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
state: state ? { id: state.id, name: state.name, color: state.color, type: state.type } : null,
assignee: assignee ? { id: assignee.id, name: assignee.name, email: assignee.email } : null,
team: team ? { id: team.id, name: team.name, key: team.key } : null,
project: project ? { id: project.id, name: project.name } : null,
projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
labels: labelsResult.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })),
attachments: includeAttachments && attachmentsResult ? attachmentsResult.nodes.map((att) => ({
id: att.id,
title: att.title,
url: att.url,
source: att.source,
metadata: att.metadata,
groupBySource: att.groupBySource,
createdAt: att.createdAt,
updatedAt: att.updatedAt
})) : void 0,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
url: issue.url
};
}
async function handleLinearError(error, entityType, entityId, contextMessage, getAvailableJsonFn) {
if (error instanceof McpError2) throw error;
const err = error;
const availableJson = typeof getAvailableJsonFn === "function" ? await getAvailableJsonFn() : getAvailableJsonFn;
let specificMessage = `Invalid ${entityType}Id: '${entityId}'. ${contextMessage}.`;
if (err.extensions?.userPresentableMessage) {
specificMessage = `${specificMessage} Details: ${err.extensions.userPresentableMessage}`;
} else if (err.message) {
if (err.message.toLowerCase().includes("not found") || err.message.toLowerCase().includes("no entity found") || err.message.toLowerCase().includes("api error") || err.message.toLowerCase().includes("invalid uuid")) {
specificMessage = `${specificMessage} ${entityType} not found or ID is invalid.`;
} else {
specificMessage = `${specificMessage} Error during validation: ${err.message}`;
}
} else {
specificMessage = `${specificMessage} An unknown error occurred during ${entityType} validation.`;
}
throw new McpError2(
ErrorCode2.InvalidParams,
`${specificMessage} Valid ${entityType}s are: ${availableJson}`
);
}
async function validateTeam(linearClient, teamId, operationContext = "") {
try {
const team = await linearClient.team(teamId);
if (!team) {
const msg = operationContext ? `${operationContext}: ` : "";
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}Team with ID '${teamId}' not found. Valid teams are: ${await getAvailableTeamsJson(linearClient)}`
);
}
return team;
} catch (e) {
return handleLinearError(
e,
"team",
teamId,
operationContext,
() => getAvailableTeamsJson(linearClient)
);
}
}
async function validateProject(linearClient, projectId, operationContext = "") {
try {
const project = await linearClient.project(projectId);
if (!project) {
const msg = operationContext ? `${operationContext}: ` : "";
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}Project with ID '${projectId}' not found. Valid projects are: ${await getAvailableProjectsJson(linearClient)}`
);
}
return project;
} catch (e) {
return handleLinearError(
e,
"project",
projectId,
operationContext,
() => getAvailableProjectsJson(linearClient)
);
}
}
async function validateState(linearClient, teamId, stateId, operationContext = "") {
const team = await validateTeam(linearClient, teamId, `for state validation ${operationContext}`);
try {
const states = await team.states({ filter: { id: { eq: stateId } } });
if (!states.nodes || states.nodes.length === 0) {
const msg = operationContext ? `${operationContext}: ` : "";
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}State with ID '${stateId}' not found for team '${team.name}'. Valid states are: ${await getAvailableStatesJson(linearClient, teamId)}`
);
}
return states.nodes[0];
} catch (e) {
return handleLinearError(
e,
"state",
stateId,
`for team '${team.name}' ${operationContext}`,
() => getAvailableStatesJson(linearClient, teamId)
);
}
}
async function validateAssignee(linearClient, assigneeId, operationContext = "") {
try {
const assignee = await linearClient.user(assigneeId);
if (!assignee || !assignee.active) {
const msg = operationContext ? `${operationContext}: ` : "";
let userStatus = "User not found.";
if (assignee && !assignee.active)
userStatus = `User '${assignee.displayName}' is not active.`;
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}Assignee with ID '${assigneeId}' is invalid. ${userStatus} Valid assignees are: ${await getAvailableAssigneesJson(linearClient)}`
);
}
return assignee;
} catch (e) {
return handleLinearError(
e,
"assignee",
assigneeId,
operationContext,
() => getAvailableAssigneesJson(linearClient)
);
}
}
async function validateLabels(linearClient, teamId, labelIds, operationContext = "") {
const team = await validateTeam(linearClient, teamId, `for label validation ${operationContext}`);
try {
const labels = await team.labels({ filter: { id: { in: labelIds } } });
const foundLabelIds = labels.nodes.map((l) => l.id);
const notFoundLabelIds = labelIds.filter((id) => !foundLabelIds.includes(id));
if (notFoundLabelIds.length > 0) {
const msg = operationContext ? `${operationContext}: ` : "";
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}Label(s) with ID(s) '${notFoundLabelIds.join(", ")}' not found for team '${team.name}'. Valid labels are: ${await getAvailableLabelsJson(linearClient, teamId)}`
);
}
} catch (e) {
return handleLinearError(
e,
"label(s)",
labelIds.join(", "),
`for team '${team.name}' ${operationContext}`,
() => getAvailableLabelsJson(linearClient, teamId)
);
}
}
async function validateProjectMilestone(linearClient, projectMilestoneId, forProjectId, operationContext = "") {
try {
const milestone = await linearClient.projectMilestone(projectMilestoneId);
if (!milestone) {
const msg = operationContext ? `${operationContext}: ` : "";
throw new McpError2(
ErrorCode2.InvalidParams,
`${msg}Project milestone with ID '${projectMilestoneId}' not found. ${await getAvailableProjectMilestonesJson(linearClient, forProjectId ?? void 0)}`
);
}
if (forProjectId && milestone.projectId !== forProjectId) {
const targetProject = await linearClient.project(forProjectId);
const actualProject = await milestone.project;
throw new McpError2(
ErrorCode2.InvalidParams,
`Project milestone '${milestone.name}' (${projectMilestoneId}) does not belong to project '${targetProject?.name ?? forProjectId}'. It belongs to '${actualProject?.name ?? milestone.projectId}'.`
);
}
return milestone;
} catch (e) {
return handleLinearError(
e,
"project milestone",
projectMilestoneId,
operationContext,
() => getAvailableProjectMilestonesJson(linearClient, forProjectId ?? void 0)
);
}
}
async function validateIssueExists(linearClient, issueId, operationContext = "") {
try {
const issue = await linearClient.issue(issueId);
if (!issue) {
let recentIssuesMessage = "";
try {
const recentIssues = await linearClient.issues({
first: 5,
orderBy: LinearDocument.PaginationOrderBy.UpdatedAt
});
if (recentIssues.nodes.length > 0) {
recentIssuesMessage = ` Recent issues: ${JSON.stringify(
recentIssues.nodes.map((iss) => ({
id: iss.id,
title: iss.title,
identifier: iss.identifier
})),
null,
2
)}`;
}
} catch {
}
throw new McpError2(
ErrorCode2.InvalidParams,
`${operationContext}: Issue with ID '${issueId}' not found.${recentIssuesMessage}`
);
}
return issue;
} catch (e) {
if (e instanceof McpError2) throw e;
throw new McpError2(
ErrorCode2.InternalError,
`Error fetching issue '${issueId}' for ${operationContext}: ${e.message}`
);
}
}
// src/tools/issues/create_comment.ts
var createCommentTool = defineTool({
name: "create_comment",
description: "Create a comment on a Linear issue by ID",
inputSchema: CommentCreateSchema,
handler: async ({ issueId, body }) => {
const linearClient = getLinearClient();
await validateIssueExists(linearClient, issueId, "creating comment");
const commentPayload = await linearClient.createComment({ issueId, body });
const newComment = await commentPayload.comment;
if (!newComment)
throw new Error(
`Failed to create comment or retrieve details. Sync ID: ${commentPayload.lastSyncId}`
);
return {
content: [
{
type: "text",
text: JSON.stringify({
id: newComment.id,
body: newComment.body,
createdAt: newComment.createdAt,
updatedAt: newComment.updatedAt,
userId: newComment.userId
})
}
]
};
}
});
// src/tools/issues/create_issue.ts
var createIssueTool = defineTool({
name: "create_issue",
description: "Create a new Linear issue",
inputSchema: IssueCreateSchema,
handler: async ({
title,
description,
teamId,
priority,
projectId,
stateId,
assigneeId,
labelIds,
dueDate,
projectMilestoneId
}) => {
const linearClient = getLinearClient();
await validateTeam(linearClient, teamId, "creating issue");
if (projectId) await validateProject(linearClient, projectId, "creating issue");
if (stateId) await validateState(linearClient, teamId, stateId, "creating issue");
if (assigneeId) await validateAssignee(linearClient, assigneeId, "creating issue");
if (labelIds && labelIds.length > 0)
await validateLabels(linearClient, teamId, labelIds, "creating issue");
if (projectMilestoneId)
await validateProjectMilestone(linearClient, projectMilestoneId, projectId, "creating issue");
const payload = {
title,
description,
teamId,
priority,
projectId,
stateId,
assigneeId,
labelIds,
dueDate,
projectMilestoneId
};
const issueCreate = await linearClient.createIssue(payload);
const newIssue = await issueCreate.issue;
if (!newIssue)
throw new Error(
`Failed to create issue or retrieve details. Sync ID: ${issueCreate.lastSyncId}`
);
const detailedNewIssue = await mapIssueToDetails(newIssue, false);
return { content: [{ type: "text", text: JSON.stringify(detailedNewIssue) }] };
}
});
// src/tools/issues/get_issue.ts
var getIssueTool = defineTool({
name: "get_issue",
description: "Retrieve a Linear issue details by ID, including attachments",
inputSchema: IdSchema,
handler: async ({ id }) => {
const linearClient = getLinearClient();
const issue = await validateIssueExists(linearClient, id, "getting issue details");
const detailedIssue = await mapIssueToDetails(issue, true);
return { content: [{ type: "text", text: JSON.stringify(detailedIssue) }] };
}
});
// src/tools/issues/get_issue_git_branch_name.ts
var getIssueGitBranchNameTool = defineTool({
name: "get_issue_git_branch_name",
description: "Retrieve the branch name for a Linear issue by ID",
inputSchema: IdSchema,
handler: async ({ id }) => {
const linearClient = getLinearClient();
const issue = await validateIssueExists(linearClient, id, "getting git branch name");
const branchName = await issue.branchName;
return {
content: [
{
type: "text",
text: JSON.stringify({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
branchName
})
}
]
};
}
});
// src/tools/issues/list_comments.ts
import { z as z3 } from "zod";
var listCommentsTool = defineTool({
name: "list_comments",
description: "Retrieve comments for a Linear issue by ID",
inputSchema: { issueId: z3.string().describe("The ID of the issue to fetch comments for") },
handler: async ({ issueId }) => {
const linearClient = getLinearClient();
const issue = await validateIssueExists(linearClient, issueId, "listing comments");
const comments = await issue.comments();
const commentDetails = comments.nodes.map((comment) => ({
id: comment.id,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
userId: comment.userId
}));
return { content: [{ type: "text", text: JSON.stringify(commentDetails) }] };
}
});
// src/tools/issues/list_issues.ts
async function validateListIssuesInput(linearClient, {
teamId,
stateId,
assigneeId,
projectMilestoneId
}) {
if (teamId) await validateTeam(linearClient, teamId, "listing issues");
if (stateId) {
if (!teamId)
throw new Error("Cannot validate stateId: 'teamId' is required when 'stateId' is provided.");
await validateState(linearClient, teamId, stateId, "listing issues");
}
if (assigneeId) await validateAssignee(linearClient, assigneeId, "listing issues");
if (projectMilestoneId)
await validateProjectMilestone(linearClient, projectMilestoneId, null, "listing issues");
}
function buildIssueFilters({
query,
teamId,
stateId,
assigneeId,
projectMilestoneId,
projectId,
includeArchived = true,
limit = 50
}) {
const filters = { includeArchived, first: limit };
if (query) {
filters.filter = {
...filters.filter || {},
or: [
{ title: { containsIgnoreCase: query } },
{ description: { containsIgnoreCase: query } }
]
};
}
if (teamId) filters.teamId = teamId;
if (stateId) {
filters.filter = {
...filters.filter || {},
state: { id: { eq: stateId } }
};
}
if (assigneeId) {
filters.filter = {
...filters.filter || {},
assignee: { id: { eq: assigneeId } }
};
}
if (projectMilestoneId) {
filters.filter = {
...filters.filter || {},
projectMilestone: { id: { eq: projectMilestoneId } }
};
}
if (projectId) {
filters.projectId = projectId;
filters.filter = {
...filters.filter || {},
project: { id: { eq: projectId } }
};
}
return filters;
}
var listIssuesTool = defineTool({
name: "list_issues",
description: "List issues in the user's Linear workspace",
inputSchema: IssueFilterSchema,
handler: async ({
query,
teamId,
stateId,
assigneeId,
projectMilestoneId,
projectId,
includeArchived = true,
limit = 50
}) => {
const linearClient = getLinearClient();
await validateListIssuesInput(linearClient, {
teamId,
stateId,
assigneeId,
projectMilestoneId
});
const filters = buildIssueFilters({
query,
teamId,
stateId,
assigneeId,
projectMilestoneId,
projectId,
includeArchived,
limit
});
const issuesConnection = await linearClient.issues(
filters
);
const issues = await Promise.all(
issuesConnection.nodes.map((issueNode) => mapIssueToDetails(issueNode, false))
);
return { content: [{ type: "text", text: JSON.stringify(issues) }] };
}
});
// src/tools/issues/update_issue.ts
import { ErrorCode as ErrorCode3, McpError as McpError3 } from "@modelcontextprotocol/sdk/types.js";
var updateIssueTool = defineTool({
name: "update_issue",
description: "Update an existing Linear issue",
inputSchema: IssueUpdateSchema,
handler: async ({
id,
title,
description,
priority,
projectId,
stateId,
assigneeId,
labelIds,
dueDate,
projectMilestoneId
}) => {
const linearClient = getLinearClient();
const issueToUpdate = await validateIssueExists(linearClient, id, "updating issue");
const currentTeamId = (await issueToUpdate.team)?.id;
const currentProjectId = (await issueToUpdate.project)?.id;
await validateInputParameters({
linearClient,
id,
currentTeamId,
currentProjectId,
projectId,
stateId,
assigneeId,
labelIds,
projectMilestoneId
});
const updatePayload = buildUpdatePayload({
title,
description,
priority,
projectId,
stateId,
assigneeId,
labelIds,
dueDate,
projectMilestoneId
});
try {
const issueUpdate = await linearClient.updateIssue(id, updatePayload);
const updatedIssue = await issueUpdate.issue;
if (!updatedIssue) {
throw new McpError3(
ErrorCode3.InternalError,
`Failed to update issue or retrieve details. Sync ID: ${issueUpdate.lastSyncId}`
);
}
const detailedUpdatedIssue = await mapIssueToDetails(updatedIssue, false);
return { content: [{ type: "text", text: JSON.stringify(detailedUpdatedIssue) }] };
} catch (error) {
if (error instanceof McpError3) throw error;
throw new McpError3(
ErrorCode3.InternalError,
`Error updating issue: ${error.message}`
);
}
}
});
async function validateInputParameters({
linearClient,
id,
currentTeamId,
currentProjectId,
projectId,
stateId,
assigneeId,
labelIds,
projectMilestoneId
}) {
if (projectId) {
await validateProject(linearClient, projectId, "updating issue");
}
if (stateId) {
if (!currentTeamId) {
throw new McpError3(
ErrorCode3.InvalidParams,
`Issue '${id}' has no team, cannot validate stateId.`
);
}
await validateState(linearClient, currentTeamId, stateId, "updating issue");
}
if (assigneeId) {
await validateAssignee(linearClient, assigneeId, "updating issue");
}
if (labelIds && labelIds.length > 0) {
if (!currentTeamId) {
throw new McpError3(
ErrorCode3.InvalidParams,
`Issue '${id}' has no team, cannot validate labelIds.`
);
}
await validateLabels(linearClient, currentTeamId, labelIds, "updating issue");
}
if (projectMilestoneId !== void 0) {
if (projectMilestoneId) {
const targetProjectId = projectId ?? currentProjectId;
await validateProjectMilestone(
linearClient,
projectMilestoneId,
targetProjectId,
"updating issue"
);
}
}
}
function buildUpdatePayload({
title,
description,
priority,
projectId,
stateId,
assigneeId,
labelIds,
dueDate,
projectMilestoneId
}) {
return {
...title !== void 0 && { title },
...description !== void 0 && { description },
...priority !== void 0 && { priority },
...projectId !== void 0 && { projectId },
...stateId !== void 0 && { stateId },
...assigneeId !== void 0 && { assigneeId },
...labelIds !== void 0 && { labelIds },
...dueDate !== void 0 && { dueDate },
...projectMilestoneId !== void 0 && { projectMilestoneId }
};
}
// src/tools/issues/index.ts
var issueTools = [
listIssuesTool,
createCommentTool,
createIssueTool,
getIssueGitBranchNameTool,
getIssueTool,
listCommentsTool,
updateIssueTool
];
// src/tools/labels/list_issue_labels.ts
import { ErrorCode as ErrorCode4, McpError as McpError4 } from "@modelcontextprotocol/sdk/types.js";
// src/tools/labels/shared.ts
import { z as z4 } from "zod";
var LabelListSchema = {
teamId: z4.string().describe("The team UUID")
};
async function getAvailableTeamsMessage(linearClient) {
try {
const allTeams = await linearClient.teams();
if (allTeams.nodes.length > 0) {
const teamList = allTeams.nodes.map((t) => ({
id: t.id,
name: t.name
}));
return ` Valid teams are: ${JSON.stringify(teamList, null, 2)}`;
}
return " No teams available to list.";
} catch {
return " (Could not fetch available teams for context.)";
}
}
function formatLabelNodes(labels) {
return labels.map((label) => ({
id: label.id,
name: label.name,
color: label.color,
description: label.description,
createdAt: label.createdAt instanceof Date ? label.createdAt.toISOString() : typeof label.createdAt === "string" ? label.createdAt : void 0,
updatedAt: label.updatedAt instanceof Date ? label.updatedAt.toISOString() : typeof label.updatedAt === "string" ? label.updatedAt : void 0
}));
}
// src/tools/labels/list_issue_labels.ts
var listIssueLabelsTool = defineTool({
name: "list_issue_labels",
description: "List available issue labels in a Linear team",
inputSchema: LabelListSchema,
handler: async ({ teamId }) => {
const linearClient = getLinearClient();
try {
const team = await linearClient.team(teamId);
if (!team) {
const availableTeamsMessage = await getAvailableTeamsMessage(linearClient);
throw new McpError4(
ErrorCode4.InvalidParams,
`Team with ID '${teamId}' not found when trying to list labels.${availableTeamsMessage}`
);
}
const labels = await team.labels();
return {
content: [
{
type: "text",
text: JSON.stringify(formatLabelNodes(labels.nodes))
}
]
};
} catch (error) {
const err = error;
throw new McpError4(
ErrorCode4.InternalError,
`Failed to list issue labels: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/labels/index.ts
var labelTools = [listIssueLabelsTool];
// src/tools/my-issues/list_my_issues.ts
import { ErrorCode as ErrorCode5, McpError as McpError5 } from "@modelcontextprotocol/sdk/types.js";
var listMyIssuesTool = defineTool({
name: "list_my_issues",
description: "List issues assigned to the current user",
inputSchema: PaginationSchema,
handler: async ({ limit, before, after }) => {
const linearClient = getLinearClient();
try {
const viewer = await linearClient.viewer;
const issues = await linearClient.issues({
filter: {
assignee: { id: { eq: viewer.id } }
},
first: limit,
before,
after
});
const myIssues = await Promise.all(
issues.nodes.map(async (issue) => {
const state = issue.state ? await issue.state : null;
const team = issue.team ? await issue.team : null;
const cycle = issue.cycle ? await issue.cycle : null;
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
state: state?.name,
team: team?.name,
cycleName: cycle?.name,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
url: issue.url
};
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(myIssues)
}
]
};
} catch (error) {
const err = error;
throw new McpError5(
ErrorCode5.InternalError,
`Failed to list my issues: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/my-issues.ts
var myIssuesTools = [listMyIssuesTool];
// src/tools/project-milestones/create_project_milestone.ts
import { ErrorCode as ErrorCode6, McpError as McpError6 } from "@modelcontextprotocol/sdk/types.js";
// src/tools/project-milestones/shared.ts
import { z as z5 } from "zod";
var ListProjectMilestonesInputSchema = z5.object({
projectId: z5.string().uuid("Invalid project ID")
});
var CreateProjectMilestoneInputSchema = z5.object({
projectId: z5.string().uuid("Invalid project ID"),
name: z5.string().min(1, "Milestone name cannot be empty"),
description: z5.string().optional(),
targetDate: z5.string().datetime({ message: "Invalid ISO date string for targetDate" }).optional()
});
var UpdateProjectMilestoneInputSchema = z5.object({
milestoneId: z5.string().uuid("Invalid milestone ID"),
name: z5.string().min(1, "Milestone name cannot be empty").optional(),
description: z5.string().optional(),
targetDate: z5.string().datetime({ message: "Invalid ISO date string for targetDate" }).optional()
});
var DeleteProjectMilestoneInputSchema = z5.object({
milestoneId: z5.string().uuid("Invalid milestone ID")
});
async function getAvailableProjectsJsonForError(linearClient) {
try {
const projects = await linearClient.projects();
if (!projects.nodes || projects.nodes.length === 0) {
return "[]";
}
const projectList = projects.nodes.map((p) => ({ id: p.id, name: p.name }));
return JSON.stringify(projectList, null, 2);
} catch (e) {
const error = e;
return `(Could not fetch available projects for context: ${error.message})`;
}
}
// src/tools/project-milestones/create_project_milestone.ts
var createProjectMilestoneTool = defineTool({
name: "create_project_milestone",
description: "Creates a new milestone within a project.",
inputSchema: CreateProjectMilestoneInputSchema.shape,
handler: async ({
projectId,
name,
description,
targetDate
}) => {
const linear = getLinearClient();
try {
const project = await linear.project(projectId);
if (!project) {
const availableProjectsJson = await getAvailableProjectsJsonForError(linear);
throw new McpError6(
ErrorCode6.InvalidParams,
`Project with ID "${projectId}" not found. Cannot create milestone. Valid projects are: ${availableProjectsJson}`
);
}
const payload = {
projectId,
name
};
if (description) payload.description = description;
if (targetDate) payload.targetDate = new Date(targetDate);
const milestoneCreatePayload = await linear.createProjectMilestone(payload);
const createdMilestone = await milestoneCreatePayload.projectMilestone;
if (!milestoneCreatePayload.success || !createdMilestone) {
throw new McpError6(
ErrorCode6.InternalError,
`Failed to create project milestone in Linear. Success: ${milestoneCreatePayload.success}, Last Sync ID: ${milestoneCreatePayload.lastSyncId}`
);
}
return {
content: [
{
type: "text",
text: JSON.stringify({
id: createdMilestone.id,
name: createdMilestone.name,
description: createdMilestone.description,
targetDate: createdMilestone.targetDate,
sortOrder: createdMilestone.sortOrder,
projectId: createdMilestone.projectId
})
}
]
};
} catch (error) {
if (error instanceof McpError6) throw error;
const err = error;
if (err.message.toLowerCase().includes("not found") && err.message.toLowerCase().includes(projectId.toLowerCase())) {
const availableProjectsJson = await getAvailableProjectsJsonForError(linear);
throw new McpError6(
ErrorCode6.InvalidParams,
`Project with ID "${projectId}" not found when creating milestone. Valid projects are: ${availableProjectsJson}`
);
}
throw new McpError6(
ErrorCode6.InternalError,
`Failed to create project milestone: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/project-milestones/delete_project_milestone.ts
import { ErrorCode as ErrorCode7, McpError as McpError7 } from "@modelcontextprotocol/sdk/types.js";
var deleteProjectMilestoneTool = defineTool({
name: "delete_project_milestone",
description: "Deletes a milestone.",
inputSchema: DeleteProjectMilestoneInputSchema.shape,
handler: async ({ milestoneId }) => {
const linear = getLinearClient();
try {
const deletePayload = await linear.deleteProjectMilestone(milestoneId);
if (!deletePayload.success) {
try {
await linear.projectMilestone(milestoneId);
throw new McpError7(
ErrorCode7.InternalError,
`Failed to delete project milestone "${milestoneId}" in Linear. Success: ${deletePayload.success}, Last Sync ID: ${deletePayload.lastSyncId}`
);
} catch (_fetchError) {
throw new McpError7(
ErrorCode7.InvalidParams,
`Project milestone with ID "${milestoneId}" not found. Please verify the milestone ID.`
);
}
}
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
message: `Project milestone "${milestoneId}" deleted successfully.`
})
}
]
};
} catch (error) {
if (error instanceof McpError7) throw error;
const err = error;
throw new McpError7(
ErrorCode7.InternalError,
`Failed to delete project milestone: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/project-milestones/list_project_milestones.ts
import { ErrorCode as ErrorCode8, McpError as McpError8 } from "@modelcontextprotocol/sdk/types.js";
var listProjectMilestonesTool = defineTool({
name: "list_project_milestones",
description: "Lists all milestones for a given project.",
inputSchema: ListProjectMilestonesInputSchema.shape,
handler: async ({ projectId }) => {
const linear = getLinearClient();
try {
const project = await linear.project(projectId);
if (!project) {
const availableProjectsJson = await getAvailableProjectsJsonForError(linear);
throw new McpError8(
ErrorCode8.InvalidParams,
`Project with ID "${projectId}" not found. Valid projects are: ${availableProjectsJson}`
);
}
const milestonesResult = await project.projectMilestones();
let resultNodes = [];
if (milestonesResult && Array.isArray(milestonesResult.nodes)) {
resultNodes = milestonesResult.nodes;
} else if (milestonesResult && typeof milestonesResult === "object" && milestonesResult !== null && "nodes" in milestonesResult && Array.isArray(milestonesResult.nodes)) {
resultNodes = milestonesResult.nodes;
} else if (Array.isArray(milestonesResult)) {
resultNodes = milestonesResult;
} else if (project.name) {
} else {
throw new McpError8(
ErrorCode8.InternalError,
"Could not retrieve milestones. Project data was inconsistent after initial fetch."
);
}
return {
content: [
{
type: "text",
text: JSON.stringify(
resultNodes.map((m) => ({
id: m.id,
name: m.name,
description: m.description,
targetDate: m.targetDate,
sortOrder: m.sortOrder,
projectId: m.projectId
}))
)
}
]
};
} catch (error) {
if (error instanceof McpError8) throw error;
const err = error;
if (err.message.toLowerCase().includes("not found") && err.message.toLowerCase().includes(projectId.toLowerCase())) {
const availableProjectsJson = await getAvailableProjectsJsonForError(linear);
throw new McpError8(
ErrorCode8.InvalidParams,
`Project with ID "${projectId}" not found when listing milestones. Valid projects are: ${availableProjectsJson}`
);
}
throw new McpError8(
ErrorCode8.InternalError,
`Failed to list project milestones: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/project-milestones/update_project_milestone.ts
import { ErrorCode as ErrorCode9, McpError as McpError9 } from "@modelcontextprotocol/sdk/types.js";
var updateProjectMilestoneTool = defineTool({
name: "update_project_milestone",
description: "Updates an existing milestone.",
inputSchema: UpdateProjectMilestoneInputSchema.shape,
handler: async ({
milestoneId,
name,
description,
targetDate
}) => {
const linear = getLinearClient();
try {
const payload = {};
if (name) payload.name = name;
if (description) payload.description = description;
if (targetDate) payload.targetDate = new Date(targetDate);
if (Object.keys(payload).length === 0) {
throw new McpError9(
ErrorCode9.InvalidParams,
"No update data provided for project milestone."
);
}
const milestoneUpdatePayload = await linear.updateProjectMilestone(milestoneId, payload);
const updatedMilestone = await milestoneUpdatePayload.projectMilestone;
if (!milestoneUpdatePayload.success || !updatedMilestone) {
try {
await linear.projectMilestone(milestoneId);
throw new McpError9(
ErrorCode9.InternalError,
`Failed to update project milestone "${milestoneId}" in Linear. Success: ${milestoneUpdatePayload.success}, Last Sync ID: ${milestoneUpdatePayload.lastSyncId}`
);
} catch (_fetchError) {
throw new McpError9(
ErrorCode9.InvalidParams,
`Project milestone with ID "${milestoneId}" not found or update failed. Please verify the milestone ID.`
);
}
}
return {
content: [
{
type: "text",
text: JSON.stringify({
id: updatedMilestone.id,
name: updatedMilestone.name,
description: updatedMilestone.description,
targetDate: updatedMilestone.targetDate,
sortOrder: updatedMilestone.sortOrder,
projectId: updatedMilestone.projectId
})
}
]
};
} catch (error) {
if (error instanceof McpError9) throw error;
const err = error;
throw new McpError9(
ErrorCode9.InternalError,
`Failed to update project milestone: ${err.message || "Unknown error"}`
);
}
}
});
// src/tools/project-milestones/index.ts
var projectMilestoneTools = [
createProjectMilestoneTool,
deleteProjectMilestoneTool,
listProjectMilestonesTool,
updateProjectMilestoneTool
];
// src/tools/projects/create_project.ts
import { ErrorCode as ErrorCode11, McpError as McpError11 } from "@modelcontextprotocol/sdk/types.js";
// src/tools/projects/shared.ts
import { ErrorCode as ErrorCode10, McpError as McpError10 } from "@modelcontextprotocol/sdk/types.js";
import { z as z6 } from "zod";
var ProjectFilterSchema = {
limit: z6.number().default(50).describe("The number of items to return"),
before: z6.string().optional().describe("A UUID to end at"),
after: z6.string().optional().describe("A UUID to start from"),
orderBy: z6.enum(["createdAt", "updatedAt"]).default("updatedAt"),
includeArchived: z6.boolean().default(false).describe("Whether to include archived projects"),
teamId: z6.string().optional().describe("A team UUID to filter by")
};
var ProjectQuerySchema = {
query: z6.string().describe("The ID or name of the project to retrieve")
};
var ProjectCreateSchema = {
name: z6.string().describe("A descriptive name of the project"),
description: z6.string().optional().describe("The description of the project as Markdown"),
content: z6.string().optional().describe("The content of the project as Markdown"),
startDate: z6.string().optional().describe("The start date of the project in ISO format"),
targetDate: z6.string().optional().describe("The target date of the project in ISO format"),
teamIds: z6.array(z6.string()).describe("The UUIDs of the teams to associate the project with")
};
var ProjectUpdateSchema = {
id: z6.string().describe("The ID of the project to update"),
name: z6.string().optional().describe("The new name of the project"),
description: z6.string().optional().describe("The new description of the project as Markdown"),
content: z6.string().optional().describe("The content of the project as Markdown"),
startDate: z6.string().optional().describe("The start date of the project in ISO format"),
targetDate: z6.string().optional().describe("The target date of the project in ISO format"),
teamIds: z6.array(z6.string()).optional().describe("The UUIDs of the teams to associate the project with")
};
async function getAvailableTeamsJson2(linearClient) {
try {
const teams = await linearClient.teams();
if (!teams.nodes || teams.nodes.length === 0) {
return "[]";
}
const teamList = teams.nodes.map((team) => ({
id: team.id,
name: team.name
}));
return JSON.stringify(teamList, null, 2);
} catch (e) {
const error = e;
return `"(Could not fetch available teams as JSON: ${error.message})"`;
}
}
async function getAvailableProjectsJson2(linearClient) {
try {
const projects = await linearClient.projects();
if (!projects.nodes || projects.nodes.length === 0) {
return "[]";
}
const projectList = projects.nodes.map((project) => ({
id: project.id,
name: pr