search_issues
Search and filter Linear project issues by status, priority, or keywords to quickly find specific tasks and track progress.
Instructions
A tool that searches for issues in Linear
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| keyword | No | Filter issues by keyword in title or description | |
| limit | No | Maximum number of issues to return (default: 50) | |
| priority | No | Filter issues by priority | |
| skip | No | Number of issues to skip (default: 0) | |
| status | No | Filter issues by status |
Implementation Reference
- The core handler function for the 'search_issues' tool. Fetches issues from Linear API, applies optional filters (status, priority, keyword), handles client-side pagination (limit/skip), formats results with helper function, and returns human-readable text plus JSON metadata.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) } ], }; } }
- Zod schema defining the input parameters for the search_issues tool: pagination (limit, skip), filters (status, priority, keyword).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"), });
- src/index.ts:31-41 (registration)Registration of the LinearSearchIssuesTool (named 'search_issues') along with other Linear tools to the MCP server using registerTool.registerTool(server, [ LinearSearchIssuesTool, LinearGetProfileTool, LinearCreateIssueTool, LinearCreateCommentTool, LinearUpdateCommentTool, LinearGetIssueTool, LinearGetTeamIdTool, LinearUpdateIssueTool, LinearGetCommentTool, ]);
- Helper function used by the handler to format each Linear issue into human-readable multi-line text including ID, title, status, priority, etc.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; }
- Mapping helper from string priority enums to numeric values used for filtering in the handler.export const PriorityStringToNumber: Record<string, number> = { 'no_priority': 0, 'urgent': 1, 'high': 2, 'medium': 3, 'low': 4 };