UNPKG

@pavi_thran_7/jira-mcp-server

Version:

MCP server for Jira integration with ticket creation and time logging

276 lines (247 loc) 8.39 kB
/** * @fileoverview Jira API client for MCP server */ import fetch from 'node-fetch'; /** * @typedef {import('./types/jira.js').JiraConfig} JiraConfig * @typedef {import('./types/jira.js').JiraApiResponse} JiraApiResponse */ /** * Jira API client class */ export class JiraClient { /** * @param {JiraConfig} config - Jira configuration */ constructor(config) { this.config = config; this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash this.auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64'); this.rateLimitDelay = 1000; // 1 second delay for rate limiting this.maxRetries = 3; } /** * Make authenticated HTTP request to Jira API * @param {string} endpoint - API endpoint path * @param {Object} options - Request options * @param {string} [options.method='GET'] - HTTP method * @param {Object} [options.body] - Request body * @param {Object} [options.headers] - Additional headers * @returns {Promise<JiraApiResponse>} API response */ async request(endpoint, options = {}) { const { method = 'GET', body, headers = {} } = options; const url = `${this.baseUrl}${endpoint}`; const requestHeaders = { 'Authorization': `Basic ${this.auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json', ...headers }; const requestOptions = { method, headers: requestHeaders }; if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { requestOptions.body = JSON.stringify(body); } let lastError; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { const response = await fetch(url, requestOptions); // Handle rate limiting if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const delay = retryAfter ? parseInt(retryAfter) * 1000 : this.rateLimitDelay * (attempt + 1); console.warn(`Rate limited. Retrying after ${delay}ms...`); await this.sleep(delay); continue; } const responseText = await response.text(); let data; try { data = responseText ? JSON.parse(responseText) : null; } catch (parseError) { data = responseText; } if (!response.ok) { return { success: false, error: this.formatError(response.status, data), statusCode: response.status, data: null }; } return { success: true, data, statusCode: response.status }; } catch (error) { lastError = error; if (attempt < this.maxRetries - 1) { console.warn(`Request failed, retrying... (${attempt + 1}/${this.maxRetries})`); await this.sleep(this.rateLimitDelay * (attempt + 1)); } } } return { success: false, error: `Network error after ${this.maxRetries} attempts: ${lastError.message}`, statusCode: 0, data: null }; } /** * Format error message based on status code and response * @param {number} statusCode - HTTP status code * @param {*} data - Response data * @returns {string} Formatted error message */ formatError(statusCode, data) { switch (statusCode) { case 401: return 'Authentication failed. Please check your email and API token.'; case 403: return 'Access denied. You may not have permission to perform this action.'; case 404: return 'Resource not found. Please check the project key, issue key, or other identifiers.'; case 400: if (data && data.errorMessages) { return `Bad request: ${data.errorMessages.join(', ')}`; } if (data && data.errors) { const errorMessages = Object.values(data.errors).flat(); return `Bad request: ${errorMessages.join(', ')}`; } return 'Bad request. Please check your input parameters.'; case 429: return 'Rate limit exceeded. Please try again later.'; case 500: return 'Internal server error. Please try again later.'; default: if (data && typeof data === 'object' && data.errorMessages) { return data.errorMessages.join(', '); } return `HTTP ${statusCode}: ${data || 'Unknown error'}`; } } /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep * @returns {Promise<void>} */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Test authentication with Jira * @returns {Promise<JiraApiResponse>} Authentication test result */ async testAuth() { return this.request('/rest/api/3/myself'); } /** * Get projects accessible to the user * @returns {Promise<JiraApiResponse>} Projects list */ async getProjects() { return this.request('/rest/api/3/project/search'); } /** * Get boards for a project * @param {string} [projectKey] - Project key to filter boards * @returns {Promise<JiraApiResponse>} Boards list */ async getBoards(projectKey) { let endpoint = '/rest/agile/1.0/board'; if (projectKey) { endpoint += `?projectKeyOrId=${projectKey}`; } return this.request(endpoint); } /** * Get issue types for a project * @param {string} projectId - Project ID * @returns {Promise<JiraApiResponse>} Issue types list */ async getIssueTypes(projectId) { return this.request(`/rest/api/3/issuetype/project?projectId=${projectId}`); } /** * Get priorities * @returns {Promise<JiraApiResponse>} Priorities list */ async getPriorities() { return this.request('/rest/api/3/priority'); } /** * Create a new issue * @param {Object} issueData - Issue creation data * @returns {Promise<JiraApiResponse>} Created issue */ async createIssue(issueData) { return this.request('/rest/api/3/issue', { method: 'POST', body: issueData }); } /** * Get issue details * @param {string} issueKey - Issue key (e.g., "PROJ-123") * @returns {Promise<JiraApiResponse>} Issue details */ async getIssue(issueKey) { return this.request(`/rest/api/3/issue/${issueKey}`); } /** * Add worklog to an issue * @param {string} issueKey - Issue key * @param {Object} worklogData - Worklog data * @returns {Promise<JiraApiResponse>} Created worklog */ async addWorklog(issueKey, worklogData) { return this.request(`/rest/api/3/issue/${issueKey}/worklog`, { method: 'POST', body: worklogData }); } /** * Get worklog schemes for a project (for billing accounts) * @param {string} projectKey - Project key * @returns {Promise<JiraApiResponse>} Worklog schemes */ async getWorklogSchemes(projectKey) { return this.request(`/rest/api/3/project/${projectKey}/worklogscheme`); } /** * Get specific worklog scheme details * @param {string} schemeId - Worklog scheme ID * @returns {Promise<JiraApiResponse>} Worklog scheme details */ async getWorklogScheme(schemeId) { return this.request(`/rest/api/3/worklogscheme/${schemeId}`); } /** * Search for users (for assignee lookup) * @param {string} query - Search query (email or name) * @returns {Promise<JiraApiResponse>} Users list */ async searchUsers(query) { return this.request(`/rest/api/3/user/search?query=${encodeURIComponent(query)}`); } /** * Add issue to board (if supported) * @param {string} boardId - Board ID * @param {string} issueKey - Issue key * @returns {Promise<JiraApiResponse>} Result */ async addIssueToBoard(boardId, issueKey) { // This is typically handled by moving the issue to a column in the board // The exact implementation depends on the board configuration return this.request(`/rest/agile/1.0/board/${boardId}/issue`, { method: 'POST', body: { issues: [issueKey] } }); } }