@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
JavaScript
/**
* @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] }
});
}
}