@aashari/mcp-server-atlassian-bitbucket
Version:
Node.js/TypeScript MCP server for Atlassian Bitbucket. Enables AI systems (LLMs) to interact with workspaces, repositories, and pull requests via tools (list, get, comment, search). Connects AI directly to version control workflows through the standard MC
264 lines (263 loc) • 14.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAtlassianCredentials = getAtlassianCredentials;
exports.fetchAtlassian = fetchAtlassian;
const logger_util_js_1 = require("./logger.util.js");
const config_util_js_1 = require("./config.util.js");
const constants_util_js_1 = require("./constants.util.js");
const error_util_js_1 = require("./error.util.js");
const response_util_js_1 = require("./response.util.js");
// Create a contextualized logger for this file
const transportLogger = logger_util_js_1.Logger.forContext('utils/transport.util.ts');
// Log transport utility initialization
transportLogger.debug('Transport utility initialized');
/**
* Get Atlassian credentials from environment variables
* @returns AtlassianCredentials object or null if credentials are missing
*/
function getAtlassianCredentials() {
const methodLogger = logger_util_js_1.Logger.forContext('utils/transport.util.ts', 'getAtlassianCredentials');
// First try standard Atlassian credentials (preferred for consistency)
const siteName = config_util_js_1.config.get('ATLASSIAN_SITE_NAME');
const userEmail = config_util_js_1.config.get('ATLASSIAN_USER_EMAIL');
const apiToken = config_util_js_1.config.get('ATLASSIAN_API_TOKEN');
// If standard credentials are available, use them
if (userEmail && apiToken) {
methodLogger.debug('Using standard Atlassian credentials');
return {
siteName,
userEmail,
apiToken,
useBitbucketAuth: false,
};
}
// If standard credentials are not available, try Bitbucket-specific credentials
const bitbucketUsername = config_util_js_1.config.get('ATLASSIAN_BITBUCKET_USERNAME');
const bitbucketAppPassword = config_util_js_1.config.get('ATLASSIAN_BITBUCKET_APP_PASSWORD');
if (bitbucketUsername && bitbucketAppPassword) {
methodLogger.debug('Using Bitbucket-specific credentials');
return {
bitbucketUsername,
bitbucketAppPassword,
useBitbucketAuth: true,
};
}
// If neither set of credentials is available, return null
methodLogger.warn('Missing Atlassian credentials. Please set either ATLASSIAN_SITE_NAME, ATLASSIAN_USER_EMAIL, and ATLASSIAN_API_TOKEN environment variables, or ATLASSIAN_BITBUCKET_USERNAME and ATLASSIAN_BITBUCKET_APP_PASSWORD for Bitbucket-specific auth.');
return null;
}
/**
* Fetch data from Atlassian API
* @param credentials Atlassian API credentials
* @param path API endpoint path (without base URL)
* @param options Request options
* @returns Response data wrapped with raw response path
*/
async function fetchAtlassian(credentials, path, options = {}) {
const methodLogger = logger_util_js_1.Logger.forContext('utils/transport.util.ts', 'fetchAtlassian');
const baseUrl = 'https://api.bitbucket.org';
// Set up auth headers based on credential type
let authHeader;
if (credentials.useBitbucketAuth) {
// Bitbucket API uses a different auth format
if (!credentials.bitbucketUsername ||
!credentials.bitbucketAppPassword) {
throw (0, error_util_js_1.createAuthInvalidError)('Missing Bitbucket username or app password');
}
authHeader = `Basic ${Buffer.from(`${credentials.bitbucketUsername}:${credentials.bitbucketAppPassword}`).toString('base64')}`;
}
else {
// Standard Atlassian API (Jira, Confluence)
if (!credentials.userEmail || !credentials.apiToken) {
throw (0, error_util_js_1.createAuthInvalidError)('Missing Atlassian credentials');
}
authHeader = `Basic ${Buffer.from(`${credentials.userEmail}:${credentials.apiToken}`).toString('base64')}`;
}
// Ensure path starts with a slash
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
// Construct the full URL
const url = `${baseUrl}${normalizedPath}`;
// Set up authentication and headers
const headers = {
Authorization: authHeader,
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
// Prepare request options
const requestOptions = {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
};
methodLogger.debug(`Calling Atlassian API: ${url}`);
// Set up timeout handling with configurable values
const defaultTimeout = config_util_js_1.config.getNumber('ATLASSIAN_REQUEST_TIMEOUT', constants_util_js_1.NETWORK_TIMEOUTS.DEFAULT_REQUEST_TIMEOUT);
const timeoutMs = options.timeout ?? defaultTimeout;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
methodLogger.warn(`Request timeout after ${timeoutMs}ms: ${url}`);
controller.abort();
}, timeoutMs);
// Add abort signal to request options
requestOptions.signal = controller.signal;
// Track API call performance
const startTime = performance.now();
try {
const response = await fetch(url, requestOptions);
clearTimeout(timeoutId);
const endTime = performance.now();
const requestDuration = (endTime - startTime).toFixed(2);
// Log the raw response status and headers
methodLogger.debug(`Raw response received: ${response.status} ${response.statusText}`, {
url,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
});
// Validate response size to prevent excessive memory usage (CWE-770)
const contentLength = response.headers.get('content-length');
if (contentLength) {
const responseSize = parseInt(contentLength, 10);
if (responseSize > constants_util_js_1.DATA_LIMITS.MAX_RESPONSE_SIZE) {
methodLogger.warn(`Response size ${responseSize} bytes exceeds limit of ${constants_util_js_1.DATA_LIMITS.MAX_RESPONSE_SIZE} bytes`);
throw (0, error_util_js_1.createApiError)(`Response size (${Math.round(responseSize / (1024 * 1024))}MB) exceeds maximum limit of ${Math.round(constants_util_js_1.DATA_LIMITS.MAX_RESPONSE_SIZE / (1024 * 1024))}MB`, 413, { responseSize, limit: constants_util_js_1.DATA_LIMITS.MAX_RESPONSE_SIZE });
}
}
if (!response.ok) {
const errorText = await response.text();
methodLogger.error(`API error: ${response.status} ${response.statusText}`, errorText);
// Try to parse the error response
let errorMessage = `${response.status} ${response.statusText}`;
let parsedBitbucketError = null;
try {
if (errorText &&
(errorText.startsWith('{') || errorText.startsWith('['))) {
const parsedError = JSON.parse(errorText);
// Extract specific error details from various Bitbucket API response formats
if (parsedError.type === 'error' &&
parsedError.error &&
parsedError.error.message) {
// Format: {"type":"error", "error":{"message":"...", "detail":"..."}}
parsedBitbucketError = parsedError.error;
errorMessage = parsedBitbucketError.message;
if (parsedBitbucketError.detail) {
errorMessage += ` Detail: ${parsedBitbucketError.detail}`;
}
}
else if (parsedError.error && parsedError.error.message) {
// Alternative error format: {"error": {"message": "..."}}
parsedBitbucketError = parsedError.error;
errorMessage = parsedBitbucketError.message;
}
else if (parsedError.errors &&
Array.isArray(parsedError.errors) &&
parsedError.errors.length > 0) {
// Format: {"errors":[{"status":400,"code":"INVALID_REQUEST_PARAMETER","title":"..."}]}
const atlassianError = parsedError.errors[0];
if (atlassianError.title) {
errorMessage = atlassianError.title;
parsedBitbucketError = atlassianError;
}
}
else if (parsedError.message) {
// Format: {"message":"Some error message"}
errorMessage = parsedError.message;
parsedBitbucketError = parsedError;
}
}
}
catch (parseError) {
methodLogger.debug(`Error parsing error response:`, parseError);
// Fall back to the default error message
}
// Log the parsed error or raw error text
methodLogger.debug('Parsed Bitbucket error:', parsedBitbucketError || errorText);
// Use parsedBitbucketError (or errorText if parsing failed) as originalError
const originalErrorForMcp = parsedBitbucketError || errorText;
// Handle common Bitbucket API error status codes
if (response.status === 401) {
throw (0, error_util_js_1.createAuthInvalidError)(`Bitbucket API: Authentication failed - ${errorMessage}`, originalErrorForMcp);
}
if (response.status === 403) {
throw (0, error_util_js_1.createApiError)(`Bitbucket API: Permission denied - ${errorMessage}`, 403, originalErrorForMcp);
}
if (response.status === 404) {
throw (0, error_util_js_1.createApiError)(`Bitbucket API: Resource not found - ${errorMessage}`, 404, originalErrorForMcp);
}
if (response.status === 429) {
throw (0, error_util_js_1.createApiError)(`Bitbucket API: Rate limit exceeded - ${errorMessage}`, 429, originalErrorForMcp);
}
if (response.status >= 500) {
throw (0, error_util_js_1.createApiError)(`Bitbucket API: Service error - ${errorMessage}`, response.status, originalErrorForMcp);
}
// For other API errors, preserve the original vendor message
throw (0, error_util_js_1.createApiError)(`Bitbucket API Error: ${errorMessage}`, response.status, originalErrorForMcp);
}
// Handle 204 No Content responses (common for DELETE operations)
if (response.status === 204) {
methodLogger.debug('Received 204 No Content response');
return { data: {}, rawResponsePath: null };
}
// Check if the response is expected to be plain text
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/plain')) {
// If we're expecting text (like a diff), return the raw text
const textResponse = await response.text();
methodLogger.debug(`Text response received (truncated)`, textResponse.substring(0, 200) + '...');
return {
data: textResponse,
rawResponsePath: null,
};
}
// Handle empty responses (some endpoints return 200/201 with no body)
const responseText = await response.text();
if (!responseText || responseText.trim() === '') {
methodLogger.debug('Received empty response body');
return { data: {}, rawResponsePath: null };
}
// For JSON responses, parse the text we already read
try {
const responseJson = JSON.parse(responseText);
methodLogger.debug(`Response body:`, responseJson);
// Save raw response to file
const rawResponsePath = (0, response_util_js_1.saveRawResponse)(url, requestOptions.method || 'GET', options.body, responseJson, response.status, parseFloat(requestDuration));
return { data: responseJson, rawResponsePath };
}
catch {
methodLogger.debug(`Could not parse response as JSON, returning raw content`);
return {
data: responseText,
rawResponsePath: null,
};
}
}
catch (error) {
clearTimeout(timeoutId);
methodLogger.error(`Request failed`, error);
// If it's already an McpError, just rethrow it
if (error instanceof error_util_js_1.McpError) {
throw error;
}
// Handle timeout errors
if (error instanceof Error && error.name === 'AbortError') {
methodLogger.error(`Request timed out after ${timeoutMs}ms: ${url}`);
throw (0, error_util_js_1.createApiError)(`Request timeout: Bitbucket API did not respond within ${timeoutMs / 1000} seconds`, 408, error);
}
// Handle network errors more explicitly
if (error instanceof TypeError) {
// TypeError is typically a network/fetch error in this context
const errorMessage = error.message || 'Network error occurred';
methodLogger.debug(`Network error details: ${errorMessage}`);
throw (0, error_util_js_1.createApiError)(`Network error while calling Bitbucket API: ${errorMessage}`, 500, // This will be classified as NETWORK_ERROR by detectErrorType
error);
}
// Handle JSON parsing errors
if (error instanceof SyntaxError) {
methodLogger.debug(`JSON parsing error: ${error.message}`);
throw (0, error_util_js_1.createApiError)(`Invalid response format from Bitbucket API: ${error.message}`, 500, error);
}
// Generic error handler for any other types of errors
throw (0, error_util_js_1.createUnexpectedError)(`Unexpected error while calling Bitbucket API: ${error instanceof Error ? error.message : String(error)}`, error);
}
}