Linear MCP Integration Server

  • src
import { LinearClient } from '@linear/sdk'; import { z } from 'zod'; import dotenv from 'dotenv'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; /* * IMPORTANT: MCP Integration Rule * ------------------------------ * When adding new functionality to this server: * 1. Update the README.md file with the new endpoint details * 2. Include the endpoint in the "Instructing Claude" section * 3. Follow the existing format: * ```http * METHOD /endpoint * ``` * Description and any required request body/parameters * * This ensures Claude can be properly instructed about all available functionality. */ // Configuration constants const DEBUG = true; const API_TIMEOUT_MS = 30000; // Increased to 30 second timeout for API calls const HEARTBEAT_INTERVAL_MS = 10000; // Reduced to 10 second heartbeat interval const SHUTDOWN_GRACE_PERIOD_MS = 5000; // 5 second grace period for shutdown const MAX_RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_MS = 2000; // Connection state tracking const connectionState = { isConnected: false, reconnectAttempts: 0, lastHeartbeat: Date.now(), }; // Utility to create a timeout promise function createTimeout(ms: number, message: string) { return new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms: ${message}`)), ms) ); } // Utility to wrap promises with timeout async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, context: string): Promise<T> { try { const result = await Promise.race([ promise, createTimeout(timeoutMs, context) ]) as T; return result; } catch (error: any) { if (error?.message?.includes('Timeout after')) { debugLog(`Operation timed out: ${context}`); } throw error; } } function debugLog(...args: any[]) { if (DEBUG) { console.error(`[DEBUG][${new Date().toISOString()}]`, ...args); } } function handleError(error: any, context: string) { const timestamp = new Date().toISOString(); console.error(`[ERROR][${timestamp}] ${context}:`, error); if (error?.response?.data) { console.error('API Response:', error.response.data); } // Log stack trace for unexpected errors if (error instanceof Error) { console.error('Stack trace:', error.stack); } } // Load environment variables dotenv.config(); debugLog('Environment variables loaded'); // Validate environment variables const envSchema = z.object({ LINEAR_API_KEY: z.string().min(1), }); const envValidation = envSchema.safeParse(process.env); if (!envValidation.success) { console.error('Environment validation failed:', envValidation.error.errors); process.exit(1); } debugLog('Environment validation successful'); // Initialize Linear client with error handling let linearClient: LinearClient; try { linearClient = new LinearClient({ apiKey: process.env.LINEAR_API_KEY, }); debugLog('Linear client initialized'); } catch (error) { handleError(error, 'Failed to initialize Linear client'); process.exit(1); } // Create the MCP server with explicit capabilities const server = new McpServer({ name: 'linear-mcp-server', version: '1.0.0', capabilities: { tools: { 'linear_create_issue': { description: 'Create a new Linear issue', parameters: { title: { type: 'string', description: 'Issue title' }, description: { type: 'string', description: 'Issue description (markdown supported)' }, teamId: { type: 'string', description: 'Team ID to create issue in' }, // priority: { type: 'number', description: 'Priority level (0-4)', minimum: 0, maximum: 4 }, projectId: { type: 'string', description: 'ID of the project to associate the issue with' }, stateId: { type: 'string', description: 'ID of the workflow state to set for the issue' } }, required: ['title', 'teamId'] }, 'linear_search_issues': { description: 'Search Linear issues with flexible filtering', parameters: { query: { type: 'string', description: 'Text to search in title/description' }, teamId: { type: 'string', description: 'Filter by team' }, status: { type: 'string', description: 'Filter by status' }, assigneeId: { type: 'string', description: 'Filter by assignee' }, priority: { type: 'number', description: 'Priority level (0-4)', minimum: 0, maximum: 4 }, limit: { type: 'number', description: 'Max results', default: 10 } } }, 'linear_sprint_issues': { description: 'Get all issues in the current sprint/iteration', parameters: { teamId: { type: 'string', description: 'Team ID to get sprint issues for' } }, required: ['teamId'] }, 'linear_search_teams': { description: 'Search and retrieve Linear teams', parameters: { query: { type: 'string', description: 'Optional text to search in team names' } } }, 'linear_filter_sprint_issues': { description: 'Filter current sprint issues by status and optionally by assignee', parameters: { teamId: { type: 'string', description: 'Team ID to get sprint issues for' }, status: { type: 'string', description: 'Status to filter by (e.g. "Pending Prod Release")' } }, required: ['teamId', 'status'] }, 'linear_get_workflow_states': { description: 'Get all available workflow states (statuses) for a team', parameters: { teamId: { type: 'string', description: 'Team ID to get workflow states for' } }, required: ['teamId'] }, 'linear_list_projects': { description: 'Get a list of all projects available with their IDs', parameters: { teamId: { type: 'string', description: 'Optional team ID to filter projects by team' }, limit: { type: 'number', description: 'Max number of projects to return', default: 50 } } } } } }); debugLog('MCP server created'); // Add Linear tools with improved error handling 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)'), projectId: z.string().optional().describe('ID of the project to associate the issue with'), stateId: z.string().optional().describe('ID of the workflow state to set for the issue') }, async (params) => { try { debugLog('Creating issue with params:', params); const issueResult = await linearClient.createIssue(params); const issueData = await issueResult.issue; if (!issueData) { console.error('Issue creation succeeded but returned no data'); 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; } } ); 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') }, async (params) => { try { debugLog('Searching issues with params:', params); const issues = await linearClient.issues({ 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 }) }); debugLog(`Found ${issues.nodes.length} issues`); const issueList = await Promise.all( issues.nodes.map(async (issue) => { const state = await issue.state; return `${issue.identifier}: ${issue.title} (${state?.name ?? 'No status'})`; }) ); return { content: [{ type: 'text', text: issueList.join('\n') }] }; } catch (error) { handleError(error, 'Failed to search issues'); throw error; } } ); server.tool( 'linear_sprint_issues', { teamId: z.string().describe('Team ID to get sprint issues for') }, async (params) => { try { debugLog('Fetching current sprint issues for team:', params.teamId); // Get the team's current cycle (sprint) const team = await linearClient.team(params.teamId); const cycles = await linearClient.cycles({ filter: { team: { id: { eq: params.teamId } }, isActive: { eq: true } } }); if (!cycles.nodes.length) { return { content: [{ type: 'text', text: 'No active sprint found for this team.' }] }; } const currentCycle = cycles.nodes[0]; // Get all issues in the current cycle const issues = await linearClient.issues({ filter: { team: { id: { eq: params.teamId } }, cycle: { id: { eq: currentCycle.id } } } }); debugLog(`Found ${issues.nodes.length} issues in current sprint`); const issueList = await Promise.all( issues.nodes.map(async (issue) => { const state = await issue.state; const assignee = await issue.assignee; return `${issue.identifier}: ${issue.title} (${state?.name ?? 'No status'})${assignee ? ` - Assigned to: ${assignee.name}` : ''}`; }) ); return { content: [{ type: 'text', text: `Current Sprint: ${currentCycle.name}\nStart: ${new Date(currentCycle.startsAt).toLocaleDateString()}\nEnd: ${new Date(currentCycle.endsAt).toLocaleDateString()}\n\nIssues:\n${issueList.join('\n')}` }] }; } catch (error) { handleError(error, 'Failed to fetch sprint issues'); throw error; } } ); server.tool( 'linear_search_teams', { query: z.string().optional().describe('Optional text to search in team names') }, async (params) => { try { debugLog('Searching teams with query:', params.query); const teams = await linearClient.teams({ ...(params.query && { filter: { name: { contains: params.query } } }) }); if (!teams.nodes.length) { return { content: [{ type: 'text', text: 'No teams found.' }] }; } debugLog(`Found ${teams.nodes.length} teams`); const teamList = await Promise.all( teams.nodes.map(async (team) => { const activeMembers = await team.members(); return `Team: ${team.name}\nID: ${team.id}\nKey: ${team.key}\nMembers: ${activeMembers.nodes.length}\n`; }) ); return { content: [{ type: 'text', text: teamList.join('\n') }] }; } catch (error) { handleError(error, 'Failed to search teams'); throw error; } } ); server.tool( 'linear_filter_sprint_issues', { teamId: z.string().describe('Team ID to get sprint issues for'), status: z.string().describe('Status to filter by') }, async (params) => { try { debugLog('Filtering sprint issues with params:', params); // Get current user info with timeout const viewer = await withTimeout( linearClient.viewer, API_TIMEOUT_MS, 'Fetching Linear user info' ); debugLog('Current user:', viewer.id); // Get the team's current cycle (sprint) with timeout const cycles = await withTimeout( linearClient.cycles({ filter: { team: { id: { eq: params.teamId } }, isActive: { eq: true } } }), API_TIMEOUT_MS, 'Fetching active cycles' ); if (!cycles.nodes.length) { return { content: [{ type: 'text', text: 'No active sprint found for this team.' }] }; } const currentCycle = cycles.nodes[0]; // Get filtered issues in the current cycle with timeout const issues = await withTimeout( linearClient.issues({ filter: { team: { id: { eq: params.teamId } }, cycle: { id: { eq: currentCycle.id } }, state: { name: { eq: params.status } }, assignee: { id: { eq: viewer.id } } } }), API_TIMEOUT_MS, 'Fetching filtered sprint issues' ); debugLog(`Found ${issues.nodes.length} matching issues in current sprint`); if (issues.nodes.length === 0) { return { content: [{ type: 'text', text: `No issues found with status "${params.status}" assigned to you in the current sprint.` }] }; } // Process issues with timeout protection for each issue's state fetch const issueList = await Promise.all( issues.nodes.map(async (issue) => { const state = issue.state ? await withTimeout( issue.state, API_TIMEOUT_MS, `Fetching state for issue ${issue.id}` ) : null; return `${issue.identifier}: ${issue.title}\n Status: ${state?.name ?? 'No status'}\n URL: ${issue.url}`; }) ); return { content: [{ type: 'text', text: `Current Sprint: ${currentCycle.name}\nStart: ${new Date(currentCycle.startsAt).toLocaleDateString()}\nEnd: ${new Date(currentCycle.endsAt).toLocaleDateString()}\n\nYour Issues with Status "${params.status}":\n\n${issueList.join('\n\n')}` }] }; } catch (error) { handleError(error, 'Failed to filter sprint issues'); throw error; } } ); // Add new tool to get workflow states server.tool( 'linear_get_workflow_states', { teamId: z.string().describe('Team ID to get workflow states for') }, async (params) => { try { debugLog('Fetching workflow states for team:', params.teamId); // Get the team's workflow states with timeout const team = await withTimeout( linearClient.team(params.teamId), API_TIMEOUT_MS, 'Fetching team info' ); const workflowStates = await withTimeout( linearClient.workflowStates({ filter: { team: { id: { eq: params.teamId } } } }), API_TIMEOUT_MS, 'Fetching workflow states' ); if (!workflowStates.nodes.length) { return { content: [{ type: 'text', text: 'No workflow states found for this team.' }] }; } debugLog(`Found ${workflowStates.nodes.length} workflow states`); // Sort workflow states by position to maintain the correct order const sortedStates = [...workflowStates.nodes].sort((a, b) => a.position - b.position); const statesList = sortedStates.map(state => { return { id: state.id, name: state.name, description: state.description || 'No description', type: state.type, position: state.position }; }); return { content: [{ type: 'text', text: `Team: ${team.name}\nWorkflow States:\n${JSON.stringify(statesList, null, 2)}` }] }; } catch (error) { handleError(error, 'Failed to fetch workflow states'); throw error; } } ); // Add new tool to list all projects server.tool( 'linear_list_projects', { teamId: z.string().optional().describe('Optional team ID to filter projects by team'), limit: z.number().default(50).describe('Max number of projects to return') }, async (params) => { try { debugLog('Fetching projects with params:', params); // Get projects with timeout let projects; if (params.teamId) { // If teamId is provided, first get the team const team = await withTimeout( linearClient.team(params.teamId), API_TIMEOUT_MS, 'Fetching team' ); // Then get projects for this team projects = await withTimeout( team.projects({ first: params.limit }), API_TIMEOUT_MS, 'Fetching projects for team' ); } else { // Get all projects projects = await withTimeout( linearClient.projects({ first: params.limit }), API_TIMEOUT_MS, 'Fetching all projects' ); } if (!projects.nodes.length) { return { content: [{ type: 'text', text: params.teamId ? `No projects found for team ID: ${params.teamId}.` : 'No projects found.' }] }; } debugLog(`Found ${projects.nodes.length} projects`); // Create a simplified list of projects with basic information const projectsList = projects.nodes.map(project => ({ id: project.id, name: project.name, description: project.description || 'No description', url: project.url, startedAt: project.startedAt ? new Date(project.startedAt).toLocaleDateString() : 'Not set', targetDate: project.targetDate ? new Date(project.targetDate).toLocaleDateString() : 'Not set' })); return { content: [{ type: 'text', text: `Projects (${projects.nodes.length}):\n${JSON.stringify(projectsList, null, 2)}` }] }; } catch (error) { handleError(error, 'Failed to fetch projects'); throw error; } } ); // Create and configure transport const transport = new StdioServerTransport(); transport.onerror = async (error: any) => { handleError(error, 'Transport error'); if (connectionState.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { connectionState.reconnectAttempts++; debugLog(`Attempting reconnection (${connectionState.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`); setTimeout(async () => { try { await server.connect(transport); connectionState.isConnected = true; debugLog('Reconnection successful'); } catch (reconnectError) { handleError(reconnectError, 'Reconnection failed'); if (connectionState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { debugLog('Max reconnection attempts reached, shutting down'); await shutdown(); } } }, RECONNECT_DELAY_MS); } else { debugLog('Max reconnection attempts reached, shutting down'); await shutdown(); } }; transport.onmessage = async (message: any) => { try { debugLog('Received message:', message?.method); if (message?.method === 'initialize') { debugLog('Handling initialize request'); connectionState.isConnected = true; connectionState.lastHeartbeat = Date.now(); } else if (message?.method === 'initialized') { debugLog('Connection fully initialized'); connectionState.isConnected = true; } else if (message?.method === 'server/heartbeat') { connectionState.lastHeartbeat = Date.now(); debugLog('Heartbeat received'); } // Set up heartbeat check const heartbeatCheck = setInterval(() => { const timeSinceLastHeartbeat = Date.now() - connectionState.lastHeartbeat; if (timeSinceLastHeartbeat > HEARTBEAT_INTERVAL_MS * 2) { debugLog('No heartbeat received, attempting reconnection'); clearInterval(heartbeatCheck); if (transport && transport.onerror) { transport.onerror(new Error('Heartbeat timeout')); } } }, HEARTBEAT_INTERVAL_MS); // Clear heartbeat check on process exit process.on('beforeExit', () => { clearInterval(heartbeatCheck); }); } catch (error) { handleError(error, 'Message handling error'); throw error; } }; // Handle graceful shutdown const shutdown = async () => { debugLog('Shutting down gracefully...'); // Close transport try { await transport.close(); debugLog('Transport closed successfully'); } catch (error) { handleError(error, 'Transport closure failed'); } process.exit(0); }; // Update signal handlers process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); // Add global error handlers process.on('uncaughtException', (error: Error) => { handleError(error, 'Uncaught Exception'); shutdown(); }); process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); shutdown(); }); // Verify Linear API connection before starting server try { debugLog('Verifying Linear API connection...'); await withTimeout(linearClient.viewer, API_TIMEOUT_MS, 'Linear API connection check'); debugLog('Linear API connection verified'); } catch (error) { handleError(error, 'Failed to verify Linear API connection'); process.exit(1); } // Connect to transport with initialization handling try { debugLog('Connecting to MCP transport...'); await server.connect(transport); debugLog('MCP server connected and ready'); } catch (error) { handleError(error, 'Failed to connect MCP server'); process.exit(1); } // Keep the process alive and handle errors process.stdin.resume(); process.stdin.on('error', (error) => { handleError(error, 'stdin error'); }); process.stdout.on('error', (error) => { handleError(error, 'stdout error'); });