Skip to main content
Glama

MCP Linear App

by zalab-inc
search-issues.ts11.3 kB
import { createSafeTool } from "../../libs/tool-utils.js"; import { z } from "zod"; import linearClient from '../../libs/client.js'; import { getPriorityLabel, formatDate, safeText } from '../../libs/utils.js'; /** * Enum for Linear issue priorities as strings for schema */ export const PriorityStringValues = ["no_priority", "urgent", "high", "medium", "low"] as const; // Define string mappings for the priorities export const PriorityStringToNumber: Record<string, number> = { 'no_priority': 0, 'urgent': 1, 'high': 2, 'medium': 3, 'low': 4 }; /** * Interface for issue data structure */ interface IssueData { id: string; title: string; description?: string | null; state?: { name: string } | null; priority: number; assignee?: unknown; createdAt: string | Date; updatedAt: string | Date; url: string; labels?: unknown; dueDate?: string | Date | null; commentsCount?: number; } /** * Format a single issue to human readable text * * @param issue The issue data * @returns Formatted human readable text */ function formatIssueToHumanReadable(issue: IssueData): string { if (!issue || !issue.id) { return "Invalid or incomplete issue data"; } // Build formatted output let result = ""; // Basic issue information result += `Id: ${issue.id}\n`; result += `Title: ${safeText(issue.title)}\n`; // Improved status handling let statusText = "No status yet"; if (issue.state) { if (typeof issue.state === 'object' && issue.state?.name) { statusText = issue.state.name; } else if (typeof issue.state === 'string') { // Handle case where state might be a string ID statusText = "Status available (ID only)"; } } result += `Status: ${statusText}\n`; // Priority result += `Priority: ${getPriorityLabel(issue.priority)}\n`; // Comments count if available if (typeof issue.commentsCount === 'number') { result += `Comments: ${issue.commentsCount}\n`; } // Due date if present if (issue.dueDate) { result += `Due date: ${formatDate(issue.dueDate)}\n`; } // URL result += `Url: ${safeText(issue.url)}\n\n`; return result; } /** * Schema for search issues tool * Includes pagination parameters and filters */ const searchIssuesSchema = z.object({ limit: z.number().optional().describe("Maximum number of issues to return (default: 50)"), skip: z.number().optional().describe("Number of issues to skip (default: 0)"), status: z.enum([ "triage", "backlog", "todo", "in_progress", "done", "canceled" ]).optional().describe("Filter issues by status"), priority: z.enum([ "no_priority", "urgent", "high", "medium", "low" ]).optional().describe("Filter issues by priority"), keyword: z.string().optional().describe("Filter issues by keyword in title or description"), }); /** * Search issues tool implementation * This tool replaces the previous get-all-issues with enhanced search functionality */ export const LinearSearchIssuesTool = createSafeTool({ name: "search_issues", description: "A tool that searches for issues in Linear", schema: searchIssuesSchema.shape, handler: async (args: z.infer<typeof searchIssuesSchema>) => { try { // Set default values for pagination const limit = typeof args.limit === 'number' ? args.limit : 50; const skip = typeof args.skip === 'number' ? args.skip : 0; // Fetch issues with pagination - we need to request more to handle skiping // Linear API has a first parameter but no skip/offset parameter const maxFetch = skip + limit; const getAllIssues = await linearClient.issues({ first: maxFetch, }); if (!getAllIssues || !getAllIssues.nodes || getAllIssues.nodes.length === 0) { return { content: [ { type: "text", text: "No issues found." }, { type: "text", text: JSON.stringify({ total: 0, limit, skip, start: 0, end: 0, hasMore: false, nextSkip: null, message: "No issues available." }, null, 2) } ], }; } // Filter issues by status if provided let filteredNodes = getAllIssues.nodes; if (args.status) { filteredNodes = filteredNodes.filter(issue => { // Handle case where state might be null or different structure if (!issue.state) return false; // Normalize the state name for comparison const stateName = typeof issue.state === 'object' ? (issue.state as { name?: string })?.name?.toLowerCase?.()?.replace?.(/\s+/g, '_') || '' : ''; const targetState = args.status?.toLowerCase().replace(/\s+/g, '_') || ''; return stateName.includes(targetState) || targetState.includes(stateName); }); } // Filter issues by priority if provided if (args.priority) { const priorityValue = PriorityStringToNumber[args.priority]; if (priorityValue !== undefined) { filteredNodes = filteredNodes.filter(issue => issue.priority === priorityValue ); } } // Filter issues by keyword if provided if (args.keyword && args.keyword.trim() !== '') { const keyword = args.keyword.toLowerCase().trim(); filteredNodes = filteredNodes.filter(issue => { const title = (issue.title || '').toLowerCase(); const description = (issue.description || '').toLowerCase(); return title.includes(keyword) || description.includes(keyword); }); } // Calculate total after filtering const totalFilteredIssues = filteredNodes.length; // If skip is provided, slice the nodes array to simulate pagination const startIndex = Math.min(skip, totalFilteredIssues); const endIndex = Math.min(startIndex + limit, totalFilteredIssues); const paginatedNodes = filteredNodes.slice(startIndex, endIndex); // Check if we have any issues to display after pagination and filtering if (paginatedNodes.length === 0) { return { content: [ { type: "text", text: "No issues found matching the search criteria." }, { type: "text", text: JSON.stringify({ total: 0, filteredTotal: totalFilteredIssues, limit, skip, start: 0, end: 0, hasMore: false, nextSkip: null, filters: { status: args.status || null, priority: args.priority || null, keyword: args.keyword || null }, message: "No issues match the search criteria." }, null, 2) } ], }; } // Format each issue to human readable text let issuesText = ""; // Add search information if filters were applied if (args.status || args.priority || args.keyword) { issuesText += "Search filters:\n"; if (args.status) issuesText += `- Status: ${args.status}\n`; if (args.priority) issuesText += `- Priority: ${args.priority}\n`; if (args.keyword) issuesText += `- Keyword: "${args.keyword}"\n`; issuesText += `\n`; } // Add note about status display limitation issuesText += "Note: Status display may not accurately reflect updated status due to API limitations.\n"; issuesText += "Use 'get_issue' with specific issue ID to see accurate status information.\n\n"; // Add each formatted issue for (const issue of paginatedNodes) { // Cast to any first to handle unknown structure of Linear API response // eslint-disable-next-line @typescript-eslint/no-explicit-any const linearIssue = issue as any; // Improved state extraction let state = null; if (linearIssue.state) { if (typeof linearIssue.state === 'object') { // Normal case: state is an object state = { name: linearIssue.state.name || "No status yet" }; } else { // Handle case where state is just an ID reference state = { name: "Status set" }; } } // Create issue data object with required fields const issueData: IssueData = { id: linearIssue.id, title: linearIssue.title, description: linearIssue.description, state: state, priority: linearIssue.priority, assignee: linearIssue.assignee, createdAt: linearIssue.createdAt, updatedAt: linearIssue.updatedAt, url: linearIssue.url, labels: linearIssue.labels, dueDate: linearIssue.dueDate, commentsCount: linearIssue.commentCount || 0 // Use commentCount property directly }; issuesText += formatIssueToHumanReadable(issueData); } // Add pagination information const hasMoreIssues = endIndex < totalFilteredIssues; const nextSkip = skip + limit; // Create metadata object const metadataObj = { total: getAllIssues.nodes.length, filteredTotal: totalFilteredIssues, limit, skip, start: startIndex + 1, end: endIndex, hasMore: hasMoreIssues, nextSkip: hasMoreIssues ? nextSkip : null, filters: { status: args.status || null, priority: args.priority || null, keyword: args.keyword || null }, message: hasMoreIssues ? `For next page, use skip: ${nextSkip}` : "No more search results available." }; return { content: [ { type: "text", text: issuesText }, { type: "text", text: JSON.stringify(metadataObj, null, 2) } ], }; } catch (error) { // Handle unexpected errors gracefully const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { content: [ { type: "text", text: `An error occurred while searching for issues:\n${errorMessage}` }, { type: "text", text: JSON.stringify({ error: true, errorMessage, total: 0, limit: typeof args.limit === 'number' ? args.limit : 50, skip: typeof args.skip === 'number' ? args.skip : 0, filters: { status: args.status || null, priority: args.priority || null, keyword: args.keyword || null }, start: 0, end: 0, hasMore: false, nextSkip: null }, null, 2) } ], }; } } });

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/zalab-inc/mcp-linear-app'

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