UNPKG

@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
"use strict"; 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); } }