Skip to main content
Glama

Linear MCP Integration Server

by skspade
issues.ts16.1 kB
import {z} from 'zod'; import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import { buildPaginationInfo, determineIssueOrderBy, getLinearClient, getPriorityLabel, getTeamByKey, getWorkflowStateByName } from '../linear/index.js'; import {API_TIMEOUT_MS, debugLog, handleError, processBatch, withTimeout} from '../utils/index.js'; /** * Register issue-related tools with the MCP server * @param server The MCP server instance */ export function registerIssueTools(server: McpServer): void { // Create issue tool server.tool( 'linear_create_issue', { title: z.string().describe('Issue title'), description: z.string().optional().describe('Issue description (markdown supported)'), teamId: z.string().describe('Team ID to create issue in'), priority: z.number().min(0).max(4).optional().describe('Priority level (0-4)'), status: z.string().optional().describe('Initial status name') }, async (params) => { try { debugLog('Creating issue with params:', params); const issueResult = await getLinearClient().createIssue(params); const issueData = await issueResult.issue; if (!issueData) { throw new Error('Issue creation succeeded but returned no data'); } debugLog('Issue created successfully:', issueData.identifier); return { content: [{ type: 'text', text: `Created issue ${issueData.identifier}: ${issueData.title}` }] }; } catch (error) { handleError(error, 'Failed to create issue'); throw error; } } ); // Search issues tool server.tool( 'linear_search_issues', { query: z.string().optional().describe('Text to search in title/description'), teamId: z.string().optional().describe('Filter by team'), status: z.string().optional().describe('Filter by status'), assigneeId: z.string().optional().describe('Filter by assignee'), priority: z.number().min(0).max(4).optional().describe('Filter by priority'), limit: z.number().default(10).describe('Max results per page'), cursor: z.string().optional().describe('Pagination cursor for fetching next page'), sortBy: z.enum(['created', 'updated', 'priority', 'title']).optional().default('updated').describe('Field to sort by'), sortDirection: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort direction') }, async (params) => { try { debugLog('Searching issues with params:', params); // Determine sort order based on parameters const orderBy = determineIssueOrderBy(params.sortBy, params.sortDirection); // Build the query with pagination support const queryParams: any = { first: params.limit, filter: { ...(params.teamId && {team: {id: {eq: params.teamId}}}), ...(params.status && {state: {name: {eq: params.status}}}), ...(params.assigneeId && {assignee: {id: {eq: params.assigneeId}}}), ...(params.priority !== undefined && {priority: {eq: params.priority}}) }, ...(params.query && {search: params.query}), orderBy, ...(params.cursor && {after: params.cursor}) }; // Execute the query with timeout protection const issues = await withTimeout( getLinearClient().issues(queryParams), API_TIMEOUT_MS, 'Searching issues' ); debugLog(`Found ${issues.nodes.length} issues, hasNextPage: ${issues.pageInfo.hasNextPage}`); // Format the issues with more details const issueList = await Promise.all( issues.nodes.map(async (issue: any) => { const [state, assignee] = await Promise.all([ issue.state ? withTimeout(issue.state, API_TIMEOUT_MS, `Fetching state for issue ${issue.id}`) : null, issue.assignee ? withTimeout(issue.assignee, API_TIMEOUT_MS, `Fetching assignee for issue ${issue.id}`) : null ]) as [{ name: string } | null, { name: string } | null]; const priorityLabel = getPriorityLabel(issue.priority); const assigneeInfo = assignee ? ` | Assignee: ${assignee.name}` : ''; return `${issue.identifier}: ${issue.title}\n Status: ${state?.name ?? 'No status'} | Priority: ${priorityLabel}${assigneeInfo}\n Created: ${new Date(issue.createdAt).toLocaleString()} | Updated: ${new Date(issue.updatedAt).toLocaleString()}\n URL: ${issue.url}`; }) ); // Build pagination information const paginationInfo = buildPaginationInfo(issues.pageInfo, params); // Combine results and pagination info const resultText = issueList.length > 0 ? `${issueList.join('\n\n')}\n\n${paginationInfo}` : `No issues found matching your criteria.\n\n${paginationInfo}`; return { content: [{ type: 'text', text: resultText }] }; } catch (error) { handleError(error, 'Failed to search issues'); throw error; } } ); // Get issue details tool server.tool( 'linear_get_issue_details', { issueId: z.string().describe('Issue ID (e.g., DATA-1284) to fetch details for') }, async (params) => { try { debugLog('Fetching detailed information for issue:', params.issueId); // Parse team identifier and issue number from the issue ID const match = params.issueId.match(/^([A-Z]+)-(\d+)$/); if (!match) { throw new Error(`Invalid issue ID format: ${params.issueId}. Expected format: TEAM-NUMBER (e.g., DATA-1284)`); } const [_, teamKey, issueNumber] = match; // Find the team by key const teams = await getLinearClient().teams({ filter: { key: {eq: teamKey} } }); if (!teams.nodes.length) { throw new Error(`Team with key "${teamKey}" not found`); } const team = teams.nodes[0]; // Find the issue by team and number const issues = await getLinearClient().issues({ filter: { team: {id: {eq: team.id}}, number: {eq: parseInt(issueNumber, 10)} } }); if (!issues.nodes.length) { throw new Error(`Issue ${params.issueId} not found`); } const issue = issues.nodes[0]; // Fetch related data with timeout protection const [state, assignee, labels, subscribers, creator, comments, attachments] = await Promise.all([ issue.state ? withTimeout(issue.state, API_TIMEOUT_MS, `Fetching state for issue ${issue.id}`) : null, issue.assignee ? withTimeout(issue.assignee, API_TIMEOUT_MS, `Fetching assignee for issue ${issue.id}`) : null, withTimeout(issue.labels(), API_TIMEOUT_MS, `Fetching labels for issue ${issue.id}`), withTimeout(issue.subscribers(), API_TIMEOUT_MS, `Fetching subscribers for issue ${issue.id}`), issue.creator ? withTimeout(issue.creator, API_TIMEOUT_MS, `Fetching creator for issue ${issue.id}`) : null, withTimeout(issue.comments(), API_TIMEOUT_MS, `Fetching comments for issue ${issue.id}`), withTimeout(issue.attachments(), API_TIMEOUT_MS, `Fetching attachments for issue ${issue.id}`) ]); // Format labels const labelsList = labels.nodes.map((label: any) => label.name).join(', '); // Format metadata section const metadata = [ `ID: ${issue.identifier}`, `Title: ${issue.title}`, `Status: ${state?.name ?? 'No status'}`, `Priority: ${issue.priority !== null ? issue.priority : 'Not set'}`, `Assignee: ${assignee?.name ?? 'Unassigned'}`, `Creator: ${creator?.name ?? 'Unknown'}`, `Created: ${new Date(issue.createdAt).toLocaleString()}`, `Updated: ${new Date(issue.updatedAt).toLocaleString()}`, `Labels: ${labelsList || 'None'}`, `Subscribers: ${subscribers.nodes.length}`, `Attachments: ${attachments.nodes.length}`, `URL: ${issue.url}` ].join('\n'); // Format full description const description = issue.description ? `\n\n## Description\n\n${issue.description}` : '\n\nNo description provided.'; // Format comments section if there are any let commentsSection = ''; if (comments.nodes.length > 0) { const commentsList = await Promise.all( comments.nodes.map(async (comment: any) => { const commentUser = await comment.user; return `### Comment by ${commentUser?.name ?? 'Unknown'} (${new Date(comment.createdAt).toLocaleString()})\n\n${comment.body}`; }) ); commentsSection = `\n\n## Comments (${comments.nodes.length})\n\n${commentsList.join('\n\n---\n\n')}`; } // Combine all sections const fullIssueDetails = `# Issue ${issue.identifier}\n\n## Metadata\n\n${metadata}${description}${commentsSection}`; return { content: [{ type: 'text', text: fullIssueDetails }] }; } catch (error) { handleError(error, 'Failed to fetch issue details'); throw error; } } ); // Bulk update status tool server.tool( 'linear_bulk_update_status', { issueIds: z.array(z.string()).describe('List of issue IDs to update'), targetStatus: z.string().describe('Target status to set for all issues') }, async (params) => { try { debugLog('Bulk updating issues:', params.issueIds, 'to status:', params.targetStatus); const results = { successful: [] as string[], failed: [] as { id: string, reason: string }[] }; // Process issues in batches of 5 await processBatch( params.issueIds, 5, async (issueId: string) => { try { // Parse team identifier and issue number const match = issueId.match(/^([A-Z]+)-(\d+)$/); if (!match) { results.failed.push({id: issueId, reason: 'Invalid format'}); return; } const [_, teamKey, issueNumber] = match; // Find the team using cache-enabled helper let team; try { team = await getTeamByKey(teamKey); } catch (error) { results.failed.push({id: issueId, reason: `Team "${teamKey}" not found`}); return; } // Find the issue const issues = await withTimeout( getLinearClient().issues({ filter: { team: {id: {eq: team.id}}, number: {eq: parseInt(issueNumber, 10)} } }), API_TIMEOUT_MS, `Fetching issue ${issueId}` ); if (!issues.nodes.length) { results.failed.push({id: issueId, reason: 'Issue not found'}); return; } // Find the target workflow state using cache-enabled helper let workflowState; try { workflowState = await getWorkflowStateByName(team.id, params.targetStatus); } catch (error) { results.failed.push({ id: issueId, reason: `Status "${params.targetStatus}" not found for team ${teamKey}` }); return; } // Update the issue await withTimeout( getLinearClient().updateIssue(issues.nodes[0].id, { stateId: workflowState.id }), API_TIMEOUT_MS, `Updating issue ${issueId}` ); results.successful.push(issueId); } catch (error) { handleError(error, `Failed to update issue ${issueId}`); results.failed.push({id: issueId, reason: 'API error'}); } }, (completed: number, total: number) => { debugLog(`Progress: ${completed}/${total} issues processed`); } ); // Format response let responseText = ''; if (results.successful.length > 0) { responseText += `## Successfully Updated (${results.successful.length})\n\n`; responseText += results.successful.join(', '); responseText += '\n\n'; } if (results.failed.length > 0) { responseText += `## Failed Updates (${results.failed.length})\n\n`; results.failed.forEach(item => { responseText += `- ${item.id}: ${item.reason}\n`; }); } return { content: [{ type: 'text', text: responseText }] }; } catch (error) { handleError(error, 'Failed to bulk update issues'); throw error; } } ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/skspade/mcp-linear-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server