MCP Server
by agentico-dev
- src
- services
import { LinearClient, Issue, Comment, IssueRelation } from '@linear/sdk';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import {
GetIssueArgs,
SearchIssuesArgs,
CreateIssueArgs,
UpdateIssueArgs,
CreateCommentArgs,
GetTeamsArgs,
DeleteIssueArgs,
LinearIssue,
LinearIssueSearchResult,
LinearComment,
LinearRelationship,
LinearTeam,
LinearUser,
extractMentions,
cleanDescription
} from '../types/linear.js';
export interface LinearClientInterface extends Pick<LinearClient, 'issue' | 'issues' | 'createIssue' | 'teams' | 'createComment' | 'viewer' | 'deleteIssue'> {}
export class LinearAPIService {
private client: LinearClientInterface;
async getTeams(args: GetTeamsArgs): Promise<LinearTeam[]> {
try {
const teams = await this.client.teams();
let filteredTeams = teams.nodes.filter(team => team && team.id); // Only require id to be present
// Apply name filter if provided
if (args.nameFilter) {
const filter = args.nameFilter.toLowerCase();
filteredTeams = filteredTeams.filter(team => {
const name = team.name || '';
const key = team.key || '';
return name.toLowerCase().includes(filter) || key.toLowerCase().includes(filter);
});
}
return filteredTeams.map(team => ({
id: team.id,
name: team.name || '', // Default to empty string if undefined
key: team.key || '', // Default to empty string if undefined
description: team.description || undefined
}));
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to fetch teams: ${error instanceof Error ? error.message : String(error)}`
);
}
}
constructor(clientOrApiKey: string | LinearClientInterface) {
if (typeof clientOrApiKey === 'string') {
if (!clientOrApiKey) {
throw new Error('LINEAR_API_KEY is required');
}
this.client = new LinearClient({ apiKey: clientOrApiKey });
} else {
this.client = clientOrApiKey;
}
}
private async getCurrentUser(): Promise<LinearUser> {
const viewer = await this.client.viewer;
return {
id: viewer.id,
name: viewer.name,
email: viewer.email
};
}
private async getComments(issue: Issue): Promise<LinearComment[]> {
const comments = await issue.comments();
return Promise.all(
comments.nodes.map(async (comment: Comment): Promise<LinearComment> => {
const user = await comment.user;
return {
id: comment.id,
body: comment.body,
userId: user?.id ?? '',
userName: user?.name,
createdAt: comment.createdAt.toISOString(),
updatedAt: comment.updatedAt?.toISOString(),
};
})
);
}
private async getRelationships(issue: Issue): Promise<LinearRelationship[]> {
const relationships: LinearRelationship[] = [];
// Get parent
const parent = await issue.parent;
if (parent) {
relationships.push({
type: 'parent',
issueId: parent.id,
identifier: parent.identifier,
title: parent.title,
});
}
// Get sub-issues
const children = await issue.children();
for (const child of children.nodes) {
relationships.push({
type: 'sub',
issueId: child.id,
identifier: child.identifier,
title: child.title,
});
}
// Get other relationships
const relations = await issue.relations();
for (const relation of relations.nodes) {
const relatedIssue = await relation.relatedIssue;
if (relatedIssue) {
relationships.push({
type: relation.type.toLowerCase() as 'related' | 'blocked' | 'blocking' | 'duplicate',
issueId: relatedIssue.id,
identifier: relatedIssue.identifier,
title: relatedIssue.title,
});
}
}
return relationships;
}
async getIssue(args: GetIssueArgs): Promise<LinearIssue> {
try {
const issue = await this.client.issue(args.issueId);
if (!issue) {
throw new McpError(ErrorCode.InvalidRequest, `Issue not found: ${args.issueId}`);
}
// Get all issue data using SDK
const [
state,
assignee,
team,
creator,
labels,
parent,
children,
relations
] = await Promise.all([
issue.state,
issue.assignee,
issue.team,
issue.creator,
issue.labels(),
issue.parent,
issue.children(),
issue.relations()
]);
const issueData = {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
estimate: issue.estimate,
dueDate: issue.dueDate,
parent: parent ? {
id: parent.id,
identifier: parent.identifier,
title: parent.title
} : undefined,
children: {
nodes: children.nodes.map(child => ({
id: child.id,
identifier: child.identifier,
title: child.title
}))
},
relations: {
nodes: relations.nodes.map(relation => ({
type: relation.type,
relatedIssue: relation.relatedIssue
}))
},
state: { name: state?.name },
assignee: { name: assignee?.name },
team: { name: team?.name },
creator: { name: creator?.name },
labels: { nodes: labels.nodes.map(label => ({ name: label.name })) }
};
// Get relationships (always included)
const relationships = await this.getRelationships(issue);
// Get comments if requested
let comments;
if (args.includeRelationships) {
comments = await this.getComments(issue);
}
// Extract mentions from description and comments
const descriptionMentions = extractMentions(issueData.description);
const commentMentions = comments?.reduce(
(acc, comment) => {
const mentions = extractMentions(comment.body);
return {
issues: [...acc.issues, ...mentions.issues],
users: [...acc.users, ...mentions.users]
};
},
{ issues: [] as string[], users: [] as string[] }
);
// Combine and deduplicate mentions
const mentionedIssues = [...new Set([
...descriptionMentions.issues,
...(commentMentions?.issues || [])
])];
const mentionedUsers = [...new Set([
...descriptionMentions.users,
...(commentMentions?.users || [])
])];
return {
id: issueData.id,
identifier: issueData.identifier,
title: issueData.title,
description: cleanDescription(issueData.description),
status: issueData.state?.name,
assignee: issueData.assignee?.name,
priority: issueData.priority,
createdAt: new Date(issueData.createdAt).toISOString(),
updatedAt: new Date(issueData.updatedAt).toISOString(),
teamName: issueData.team?.name,
creatorName: issueData.creator?.name,
labels: issueData.labels.nodes.map((label: { name: string }) => label.name),
estimate: issueData.estimate,
dueDate: issueData.dueDate ? new Date(issueData.dueDate).toISOString() : undefined,
parent: issueData.parent ? {
id: issueData.parent.id,
identifier: issueData.parent.identifier,
title: issueData.parent.title
} : undefined,
subIssues: issueData.children.nodes.map((child: { id: string; identifier: string; title: string }) => ({
id: child.id,
identifier: child.identifier,
title: child.title
})),
comments,
relationships,
mentionedIssues,
mentionedUsers,
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
error instanceof Error ? error.message : String(error)
);
}
}
async createIssue(args: CreateIssueArgs): Promise<LinearIssue> {
// If parentId provided, verify it exists and get its team
if (args.parentId) {
const parent = await this.client.issue(args.parentId);
if (!parent) {
throw new McpError(ErrorCode.InvalidRequest, `Parent issue not found: ${args.parentId}`);
}
// Use parent's team if not explicitly provided
if (!args.teamId) {
const parentTeam = await parent.team;
if (!parentTeam) {
throw new McpError(ErrorCode.InvalidRequest, `Could not get team from parent issue: ${args.parentId}`);
}
args.teamId = parentTeam.id;
}
}
try {
// If no teamId provided and no parentId, throw error
if (!args.teamId && !args.parentId) {
throw new McpError(ErrorCode.InvalidRequest, 'Either teamId or parentId must be provided');
}
// Handle self-assignment
let assigneeId = args.assigneeId;
if (assigneeId === 'me') {
const currentUser = await this.getCurrentUser();
assigneeId = currentUser.id;
}
// Create the issue
const createdIssue = await this.client.createIssue({
teamId: args.teamId!, // We know it's defined because of the check above
title: args.title,
description: args.description,
priority: args.priority,
assigneeId,
parentId: args.parentId,
labelIds: args.labelIds
}).then(response => response.issue);
if (!createdIssue) {
throw new McpError(ErrorCode.InternalError, 'Failed to create issue: No issue returned');
}
// Return full issue details using existing getIssue method
return this.getIssue({ issueId: createdIssue.id });
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to create issue: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async updateIssue(args: UpdateIssueArgs): Promise<LinearIssue> {
try {
const issue = await this.client.issue(args.issueId);
if (!issue) {
throw new McpError(ErrorCode.InvalidRequest, `Issue not found: ${args.issueId}`);
}
// Handle self-assignment
let assigneeId = args.assigneeId;
if (assigneeId === 'me') {
const currentUser = await this.getCurrentUser();
assigneeId = currentUser.id;
}
// Prepare update payload with only defined fields
const updatePayload: Record<string, any> = {};
if (args.title !== undefined) updatePayload.title = args.title;
if (args.description !== undefined) updatePayload.description = args.description;
if (args.status !== undefined) updatePayload.status = args.status;
if (args.priority !== undefined) updatePayload.priority = args.priority;
if (assigneeId !== undefined) updatePayload.assigneeId = assigneeId;
if (args.labelIds !== undefined) updatePayload.labelIds = args.labelIds;
// Update the issue
await issue.update(updatePayload);
// Return full issue details using existing getIssue method
return this.getIssue({ issueId: args.issueId });
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to update issue: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async createComment(args: CreateCommentArgs): Promise<LinearComment> {
try {
// Verify issue exists
const issue = await this.client.issue(args.issueId);
if (!issue) {
throw new McpError(ErrorCode.InvalidRequest, `Issue not found: ${args.issueId}`);
}
// Create comment using the client
const result = await (this.client as LinearClient).createComment({
issueId: issue.id,
body: args.body
});
if (!result.success || !result.comment) {
throw new McpError(ErrorCode.InternalError, 'Failed to create comment');
}
// Get the created comment
const comment = await result.comment;
const user = await comment.user;
// Format response using our existing comment structure
return {
id: comment.id,
body: comment.body,
userId: user?.id ?? '',
userName: user?.name,
createdAt: comment.createdAt.toISOString(),
updatedAt: comment.updatedAt?.toISOString(),
};
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to create comment: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async deleteIssue(args: DeleteIssueArgs): Promise<void> {
// Verify issue exists
const issue = await this.client.issue(args.issueId);
if (!issue) {
throw new McpError(ErrorCode.InvalidRequest, `Issue not found: ${args.issueId}`);
}
try {
// Delete the issue
await issue.delete();
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to delete issue: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async searchIssues(args: SearchIssuesArgs): Promise<LinearIssueSearchResult[]> {
try {
// Build filter conditions
const conditions: any[] = [];
// Add search query condition if provided
if (args.query) {
conditions.push({
or: [
{ title: { contains: args.query } },
{ description: { contains: args.query } },
],
});
}
// Handle user filters
if (args.filter) {
const currentUser = args.filter.assignedTo === 'me' || args.filter.createdBy === 'me'
? await this.getCurrentUser()
: null;
if (args.filter.assignedTo) {
conditions.push({
assignee: {
id: { eq: args.filter.assignedTo === 'me' ? currentUser!.id : args.filter.assignedTo }
}
});
}
if (args.filter.createdBy) {
conditions.push({
creator: {
id: { eq: args.filter.createdBy === 'me' ? currentUser!.id : args.filter.createdBy }
}
});
}
}
// Combine all conditions with AND
const filter = conditions.length > 0
? { and: conditions }
: undefined;
const issues = await this.client.issues({ filter });
return Promise.all(
issues.nodes.map(async (issue) => {
const [state, assignee, team, labels] = await Promise.all([
issue.state,
issue.assignee,
issue.team,
issue.labels(),
]);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: state?.name,
assignee: assignee?.name,
priority: issue.priority,
teamName: team?.name,
labels: labels.nodes.map(label => label.name),
};
})
);
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
error instanceof Error ? error.message : String(error)
);
}
}
}