@zhanglc77/bitbucket-mcp-server
Version:
MCP server for Bitbucket API integration - supports both Cloud and Server
308 lines • 14 kB
JavaScript
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { isListDirectoryContentArgs, isGetFileContentArgs } from '../types/guards.js';
import * as path from 'path';
export class FileHandlers {
apiClient;
baseUrl;
// Default lines by file extension
DEFAULT_LINES_BY_EXT = {
'.yml': 200, '.yaml': 200, '.json': 200, // Config files
'.md': 300, '.txt': 300, // Docs
'.ts': 500, '.js': 500, '.py': 500, // Code
'.tsx': 500, '.jsx': 500, '.java': 500, // More code
'.log': -100 // Last 100 lines for logs
};
constructor(apiClient, baseUrl) {
this.apiClient = apiClient;
this.baseUrl = baseUrl;
}
async handleListDirectoryContent(args) {
if (!isListDirectoryContentArgs(args)) {
throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for list_directory_content');
}
const { workspace, repository, path: dirPath = '', branch } = args;
try {
let apiPath;
let params = {};
let response;
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/browse`;
if (dirPath) {
apiPath += `/${dirPath}`;
}
if (branch) {
params.at = `refs/heads/${branch}`;
}
response = await this.apiClient.makeRequest('get', apiPath, undefined, { params });
}
else {
// Bitbucket Cloud API
const branchOrDefault = branch || 'HEAD';
apiPath = `/repositories/${workspace}/${repository}/src/${branchOrDefault}`;
if (dirPath) {
apiPath += `/${dirPath}`;
}
response = await this.apiClient.makeRequest('get', apiPath);
}
// Format the response
let contents = [];
let actualBranch = branch;
if (this.apiClient.getIsServer()) {
// Bitbucket Server response
const entries = response.children?.values || [];
contents = entries.map((entry) => ({
name: entry.path.name,
type: entry.type === 'FILE' ? 'file' : 'directory',
size: entry.size,
path: dirPath ? `${dirPath}/${entry.path.name}` : entry.path.name
}));
// Get the actual branch from the response if available
if (!branch && response.path?.components) {
// Server returns default branch info in the response
actualBranch = 'default';
}
}
else {
// Bitbucket Cloud response
const entries = response.values || [];
contents = entries.map((entry) => ({
name: entry.path.split('/').pop() || entry.path,
type: entry.type === 'commit_file' ? 'file' : 'directory',
size: entry.size,
path: entry.path
}));
// Cloud returns the branch in the response
actualBranch = branch || response.commit?.branch || 'main';
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
path: dirPath || '/',
branch: actualBranch,
contents,
total_items: contents.length
}, null, 2),
},
],
};
}
catch (error) {
return this.apiClient.handleApiError(error, `listing directory '${dirPath}' in ${workspace}/${repository}`);
}
}
async handleGetFileContent(args) {
if (!isGetFileContentArgs(args)) {
throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for get_file_content');
}
const { workspace, repository, file_path, branch, start_line, line_count, full_content = false } = args;
try {
let fileContent;
let fileMetadata = {};
const fileSizeLimit = 1024 * 1024; // 1MB default limit
if (this.apiClient.getIsServer()) {
// Bitbucket Server - get file metadata first to check size
const browsePath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/browse/${file_path}`;
const browseParams = {};
if (branch) {
browseParams.at = `refs/heads/${branch}`;
}
try {
const metadataResponse = await this.apiClient.makeRequest('get', browsePath, undefined, { params: browseParams });
fileMetadata = {
size: metadataResponse.size || 0,
path: file_path
};
// Check file size
if (!full_content && fileMetadata.size > fileSizeLimit) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: 'File too large',
file_path,
size: fileMetadata.size,
size_mb: (fileMetadata.size / (1024 * 1024)).toFixed(2),
message: `File exceeds size limit. Use full_content: true to force retrieval or use start_line/line_count for partial content.`
}, null, 2),
},
],
isError: true,
};
}
}
catch (e) {
// If browse fails, continue to try raw endpoint
}
// Get raw content
const rawPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/raw/${file_path}`;
const rawParams = {};
if (branch) {
rawParams.at = `refs/heads/${branch}`;
}
const response = await this.apiClient.makeRequest('get', rawPath, undefined, {
params: rawParams,
responseType: 'text',
headers: { 'Accept': 'text/plain' }
});
fileContent = response;
}
else {
// Bitbucket Cloud - first get metadata
const branchOrDefault = branch || 'HEAD';
const metaPath = `/repositories/${workspace}/${repository}/src/${branchOrDefault}/${file_path}`;
const metadataResponse = await this.apiClient.makeRequest('get', metaPath);
fileMetadata = {
size: metadataResponse.size,
encoding: metadataResponse.encoding,
path: metadataResponse.path,
commit: metadataResponse.commit
};
// Check file size
if (!full_content && fileMetadata.size > fileSizeLimit) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: 'File too large',
file_path,
size: fileMetadata.size,
size_mb: (fileMetadata.size / (1024 * 1024)).toFixed(2),
message: `File exceeds size limit. Use full_content: true to force retrieval or use start_line/line_count for partial content.`
}, null, 2),
},
],
isError: true,
};
}
// Follow the download link to get actual content
const downloadUrl = metadataResponse.links.download.href;
const downloadResponse = await this.apiClient.makeRequest('get', downloadUrl, undefined, {
baseURL: '', // Use full URL
responseType: 'text',
headers: { 'Accept': 'text/plain' }
});
fileContent = downloadResponse;
}
// Apply line filtering if requested
let processedContent = fileContent;
let lineInfo = null;
if (!full_content || start_line !== undefined || line_count !== undefined) {
const lines = fileContent.split('\n');
const totalLines = lines.length;
// Determine default line count based on file extension
const ext = path.extname(file_path).toLowerCase();
const defaultLineCount = this.DEFAULT_LINES_BY_EXT[ext] || 500;
const shouldUseTail = defaultLineCount < 0;
// Calculate start and end indices
let startIdx;
let endIdx;
if (start_line !== undefined) {
if (start_line < 0) {
// Negative start_line means from end
startIdx = Math.max(0, totalLines + start_line);
endIdx = totalLines;
}
else {
// 1-based to 0-based index
startIdx = Math.max(0, start_line - 1);
endIdx = startIdx + (line_count || Math.abs(defaultLineCount));
}
}
else if (!full_content && fileMetadata.size > 50 * 1024) {
// Auto-truncate large files
if (shouldUseTail) {
startIdx = Math.max(0, totalLines + defaultLineCount);
endIdx = totalLines;
}
else {
startIdx = 0;
endIdx = Math.abs(defaultLineCount);
}
}
else {
// Return full content for small files
startIdx = 0;
endIdx = totalLines;
}
// Ensure indices are within bounds
startIdx = Math.max(0, Math.min(startIdx, totalLines));
endIdx = Math.max(startIdx, Math.min(endIdx, totalLines));
// Extract the requested lines
const selectedLines = lines.slice(startIdx, endIdx);
processedContent = selectedLines.join('\n');
lineInfo = {
total_lines: totalLines,
returned_lines: {
start: startIdx + 1,
end: endIdx
},
truncated: startIdx > 0 || endIdx < totalLines,
message: endIdx < totalLines
? `Showing lines ${startIdx + 1}-${endIdx} of ${totalLines}. File size: ${(fileMetadata.size / 1024).toFixed(1)}KB`
: null
};
}
// Build response
const response = {
file_path,
branch: branch || (this.apiClient.getIsServer() ? 'default' : 'main'),
size: fileMetadata.size || fileContent.length,
encoding: fileMetadata.encoding || 'utf-8',
content: processedContent
};
if (lineInfo) {
response.line_info = lineInfo;
}
if (fileMetadata.commit) {
response.last_modified = {
commit_id: fileMetadata.commit.hash,
author: fileMetadata.commit.author?.user?.display_name || fileMetadata.commit.author?.raw,
date: fileMetadata.commit.date,
message: fileMetadata.commit.message
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
};
}
catch (error) {
// Handle specific not found error
if (error.status === 404) {
return {
content: [
{
type: 'text',
text: `File '${file_path}' not found in ${workspace}/${repository}${branch ? ` on branch '${branch}'` : ''}`,
},
],
isError: true,
};
}
return this.apiClient.handleApiError(error, `getting file content for '${file_path}' in ${workspace}/${repository}`);
}
}
// Helper method to get default line count based on file extension
getDefaultLines(filePath, fileSize) {
// Small files: return full content
if (fileSize < 50 * 1024) { // 50KB
return { full: true };
}
const ext = path.extname(filePath).toLowerCase();
const defaultLines = this.DEFAULT_LINES_BY_EXT[ext] || 500;
return {
start: defaultLines < 0 ? defaultLines : 1,
count: Math.abs(defaultLines)
};
}
}
//# sourceMappingURL=file-handlers.js.map