index.ts•14.7 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { LinearClient } from '@linear/sdk';
// Create a Linear client with the OAuth token
const linearClient = new LinearClient({
  accessToken: process.env.LINEAR_REFRESH_TOKEN || process.env.LINEAR_ACCESS_TOKEN
});
// Define types for Linear API
interface LinearFilter {
  search?: string;
  team?: {
    id: {
      in: string[];
    };
  };
}
// Define tool schemas
const toolSchemas = [
  {
    name: 'linear_get_teams',
    description: 'Get all teams with their states and labels',
    inputSchema: {
      type: 'object',
      properties: {}
    }
  },
  {
    name: 'linear_search_issues',
    description: 'Search for issues with filtering and pagination',
    inputSchema: {
      type: 'object',
      properties: {
        query: {
          type: 'string',
          description: 'Search query string'
        },
        teamIds: {
          type: 'array',
          items: {
            type: 'string'
          },
          description: 'Filter by team IDs'
        },
        first: {
          type: 'number',
          description: 'Number of issues to return (default: 50)'
        }
      }
    }
  },
  {
    name: 'linear_get_cycles',
    description: 'Get all cycles for a team',
    inputSchema: {
      type: 'object',
      properties: {
        teamId: {
          type: 'string',
          description: 'Team ID to get cycles for'
        }
      },
      required: ['teamId']
    }
  },
  {
    name: 'linear_get_projects',
    description: 'Get all projects',
    inputSchema: {
      type: 'object',
      properties: {
        teamId: {
          type: 'string',
          description: 'Optional team ID to filter projects by'
        },
        first: {
          type: 'number',
          description: 'Number of projects to return (default: 50)'
        }
      }
    }
  },
  {
    name: 'linear_create_issue',
    description: 'Create a new issue',
    inputSchema: {
      type: 'object',
      properties: {
        teamId: {
          type: 'string',
          description: 'Team ID to create the issue in'
        },
        title: {
          type: 'string',
          description: 'Title of the issue'
        },
        description: {
          type: 'string',
          description: 'Description of the issue'
        },
        assigneeId: {
          type: 'string',
          description: 'ID of the user to assign the issue to'
        },
        stateId: {
          type: 'string',
          description: 'ID of the state to set for the issue'
        },
        priority: {
          type: 'number',
          description: 'Priority of the issue (0-4)'
        },
        estimate: {
          type: 'number',
          description: 'Estimate of the issue'
        },
        cycleId: {
          type: 'string',
          description: 'ID of the cycle to add the issue to'
        },
        projectId: {
          type: 'string',
          description: 'ID of the project to add the issue to'
        },
        labelIds: {
          type: 'array',
          items: {
            type: 'string'
          },
          description: 'IDs of labels to add to the issue'
        }
      },
      required: ['teamId', 'title']
    }
  },
  {
    name: 'linear_update_issue',
    description: 'Update an existing issue',
    inputSchema: {
      type: 'object',
      properties: {
        issueId: {
          type: 'string',
          description: 'ID of the issue to update'
        },
        title: {
          type: 'string',
          description: 'New title of the issue'
        },
        description: {
          type: 'string',
          description: 'New description of the issue'
        },
        assigneeId: {
          type: 'string',
          description: 'ID of the user to assign the issue to'
        },
        stateId: {
          type: 'string',
          description: 'ID of the state to set for the issue'
        },
        priority: {
          type: 'number',
          description: 'Priority of the issue (0-4)'
        },
        estimate: {
          type: 'number',
          description: 'Estimate of the issue'
        },
        cycleId: {
          type: 'string',
          description: 'ID of the cycle to add the issue to'
        },
        projectId: {
          type: 'string',
          description: 'ID of the project to add the issue to'
        },
        labelIds: {
          type: 'array',
          items: {
            type: 'string'
          },
          description: 'IDs of labels to add to the issue'
        }
      },
      required: ['issueId']
    }
  }
];
// Create the MCP server
const server = new Server(
  {
    name: 'linear-server',
    version: '0.1.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
// Set up request handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
  console.error('Handling mcp.listTools request');
  return {
    tools: toolSchemas
  };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  console.error(`Handling mcp.callTool request for tool: ${request.params.name}`);
  
  if (request.params.name === 'linear_get_teams') {
    try {
      console.error('Getting teams from Linear');
      const teams = await linearClient.teams();
      
      console.error(`Found ${teams.nodes.length} teams`);
      
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({ teams }, null, 2)
          }
        ]
      };
    } catch (error: any) {
      console.error('Error getting teams:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to get teams: ${error.message}`
      );
    }
  } else if (request.params.name === 'linear_search_issues') {
    try {
      console.error('Searching issues in Linear');
      const filter: LinearFilter = {};
      
      if (request.params.arguments && typeof request.params.arguments.query === 'string') {
        filter.search = request.params.arguments.query;
      }
      
      if (request.params.arguments && Array.isArray(request.params.arguments.teamIds)) {
        filter.team = { id: { in: request.params.arguments.teamIds as string[] } };
      }
      
      const first: number = (request.params.arguments && typeof request.params.arguments.first === 'number') 
        ? request.params.arguments.first 
        : 50;
      
      const issues = await linearClient.issues({
        filter,
        first
      });
      
      console.error(`Found ${issues.nodes.length} issues`);
      
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({ issues }, null, 2)
          }
        ]
      };
    } catch (error: any) {
      console.error('Error searching issues:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to search issues: ${error.message}`
      );
    }
  } else if (request.params.name === 'linear_get_cycles') {
    try {
      console.error('Getting cycles from Linear');
      
      if (!request.params.arguments || !request.params.arguments.teamId) {
        throw new Error('Team ID is required');
      }
      
      const teamId = request.params.arguments.teamId as string;
      const team = await linearClient.team(teamId);
      const cycles = await team.cycles();
      
      console.error(`Found ${cycles.nodes.length} cycles for team ${teamId}`);
      
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({ cycles }, null, 2)
          }
        ]
      };
    } catch (error: any) {
      console.error('Error getting cycles:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to get cycles: ${error.message}`
      );
    }
  } else if (request.params.name === 'linear_get_projects') {
    try {
      console.error('Getting projects from Linear');
      
      const first: number = (request.params.arguments && typeof request.params.arguments.first === 'number') 
        ? request.params.arguments.first 
        : 50;
      
      // If teamId is provided, get projects for that team directly
      if (request.params.arguments && typeof request.params.arguments.teamId === 'string') {
        const teamId = request.params.arguments.teamId;
        const team = await linearClient.team(teamId);
        const projects = await team.projects({ first });
        
        console.error(`Found ${projects.nodes.length} projects for team ${teamId}`);
        
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({ projects }, null, 2)
            }
          ]
        };
      } else {
        // No team specified, return all projects
        const projects = await linearClient.projects({ first });
        console.error(`Found ${projects.nodes.length} projects`);
        
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({ projects }, null, 2)
            }
          ]
        };
      }
    } catch (error: any) {
      console.error('Error getting projects:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to get projects: ${error.message}`
      );
    }
  } else if (request.params.name === 'linear_create_issue') {
    try {
      console.error('Creating issue in Linear');
      
      if (!request.params.arguments || !request.params.arguments.teamId || !request.params.arguments.title) {
        throw new Error('Team ID and title are required');
      }
      
      const issueInput: any = {
        teamId: request.params.arguments.teamId,
        title: request.params.arguments.title,
      };
      
      // Add optional fields if provided
      if (request.params.arguments.description) {
        issueInput.description = request.params.arguments.description;
      }
      
      if (request.params.arguments.assigneeId) {
        issueInput.assigneeId = request.params.arguments.assigneeId;
      }
      
      if (request.params.arguments.stateId) {
        issueInput.stateId = request.params.arguments.stateId;
      }
      
      if (request.params.arguments.priority !== undefined) {
        issueInput.priority = request.params.arguments.priority;
      }
      
      if (request.params.arguments.estimate !== undefined) {
        issueInput.estimate = request.params.arguments.estimate;
      }
      
      if (request.params.arguments.cycleId) {
        issueInput.cycleId = request.params.arguments.cycleId;
      }
      
      if (request.params.arguments.projectId) {
        issueInput.projectId = request.params.arguments.projectId;
      }
      
      if (request.params.arguments.labelIds) {
        issueInput.labelIds = request.params.arguments.labelIds;
      }
      
      const issuePayload = await linearClient.createIssue(issueInput);
      
      console.error(`Created issue with ID ${(issuePayload as any).success ? (issuePayload as any).issue?.id : 'unknown'}`);
      
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({ issuePayload }, null, 2)
          }
        ]
      };
    } catch (error: any) {
      console.error('Error creating issue:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to create issue: ${error.message}`
      );
    }
  } else if (request.params.name === 'linear_update_issue') {
    try {
      console.error('Updating issue in Linear');
      
      if (!request.params.arguments || !request.params.arguments.issueId) {
        throw new Error('Issue ID is required');
      }
      
      const issueId = request.params.arguments.issueId as string;
      const issueInput: any = {};
      
      // Add fields if provided
      if (request.params.arguments.title) {
        issueInput.title = request.params.arguments.title;
      }
      
      if (request.params.arguments.description) {
        issueInput.description = request.params.arguments.description;
      }
      
      if (request.params.arguments.assigneeId) {
        issueInput.assigneeId = request.params.arguments.assigneeId;
      }
      
      if (request.params.arguments.stateId) {
        issueInput.stateId = request.params.arguments.stateId;
      }
      
      if (request.params.arguments.priority !== undefined) {
        issueInput.priority = request.params.arguments.priority;
      }
      
      if (request.params.arguments.estimate !== undefined) {
        issueInput.estimate = request.params.arguments.estimate;
      }
      
      if (request.params.arguments.cycleId) {
        issueInput.cycleId = request.params.arguments.cycleId;
      }
      
      if (request.params.arguments.projectId) {
        issueInput.projectId = request.params.arguments.projectId;
      }
      
      if (request.params.arguments.labelIds) {
        issueInput.labelIds = request.params.arguments.labelIds;
      }
      
      const issuePayload = await linearClient.updateIssue(issueId, issueInput);
      
      console.error(`Updated issue with ID ${(issuePayload as any).success ? (issuePayload as any).issue?.id : 'unknown'}`);
      
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({ issuePayload }, null, 2)
          }
        ]
      };
    } catch (error: any) {
      console.error('Error updating issue:', error);
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to update issue: ${error.message}`
      );
    }
  } else {
    throw new McpError(
      ErrorCode.MethodNotFound,
      `Unknown tool: ${request.params.name}`
    );
  }
});
// Set up error handler
server.onerror = (error: unknown) => {
  console.error('MCP Server Error:', error);
};
// Start the server
async function main() {
  console.error('Starting Linear MCP server');
  
  // Test the Linear client
  try {
    const me = await linearClient.viewer;
    console.error(`Authenticated as: ${me.name} (${me.email})`);
  } catch (error: any) {
    console.error('Authentication failed:', error.message);
    process.exit(1);
  }
  
  const transport = new StdioServerTransport();
  await server.connect(transport);
  
  console.error('Linear MCP server running on stdio');
}
main().catch((error: unknown) => {
  console.error('Error starting server:', error);
  process.exit(1);
});