feat: add file and directory handling tools

- Added list_directory_content tool for browsing repository files/directories
- Added get_file_content tool with smart truncation for large files
- Smart defaults by file type (config: 200, docs: 300, code: 500 lines)
- Pagination support with start_line and line_count parameters
- Tail functionality using negative start_line values
- Auto-truncation for files >50KB, explicit approval needed for >1MB files
- Returns metadata including size, encoding, and last modified info
- Added FileHandlers class following modular architecture
- Added TypeScript interfaces and type guards
- Updated version to 0.5.0 and CHANGELOG
This commit is contained in:
pdogra1299 2025-06-21 17:25:00 +05:30
parent b1a25d2a05
commit c6d5b0a6f5
8 changed files with 645 additions and 2 deletions

View file

@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0] - 2025-01-21
### Added
- **New file and directory handling tools**:
- `list_directory_content` - List files and directories in any repository path
- Shows file/directory type, size, and full paths
- Supports browsing specific branches
- Works with both Bitbucket Server and Cloud APIs
- `get_file_content` - Retrieve file content with smart truncation for large files
- Automatic smart defaults by file type (config: 200 lines, docs: 300 lines, code: 500 lines)
- Pagination support with `start_line` and `line_count` parameters
- Tail functionality using negative `start_line` values (e.g., -50 for last 50 lines)
- Automatic truncation for files >50KB to prevent token overload
- Files >1MB require explicit `full_content: true` parameter
- Returns metadata including file size, encoding, and last modified info
- Added `FileHandlers` class following existing modular architecture patterns
- Added TypeScript interfaces for file/directory entries and metadata
- Added type guards `isListDirectoryContentArgs` and `isGetFileContentArgs`
### Changed
- Enhanced documentation with comprehensive examples for file handling tools
## [0.4.0] - 2025-01-21 ## [0.4.0] - 2025-01-21
### Added ### Added

100
README.md
View file

@ -23,6 +23,10 @@ An MCP (Model Context Protocol) server that provides tools for interacting with
- `delete_branch` - Delete branches (with protection checks) - `delete_branch` - Delete branches (with protection checks)
- `get_branch` - Get detailed branch information including associated PRs - `get_branch` - Get detailed branch information including associated PRs
#### File and Directory Tools
- `list_directory_content` - List files and directories in a repository path
- `get_file_content` - Get file content with smart truncation for large files
#### Code Review Tools #### Code Review Tools
- `get_pull_request_diff` - Get the diff/changes for a pull request - `get_pull_request_diff` - Get the diff/changes for a pull request
- `approve_pull_request` - Approve a pull request - `approve_pull_request` - Approve a pull request
@ -419,6 +423,102 @@ This tool is particularly useful for:
} }
``` ```
### List Directory Content
```typescript
{
"tool": "list_directory_content",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"path": "src/components", // Optional (defaults to root)
"branch": "main" // Optional (defaults to default branch)
}
}
```
Returns directory listing with:
- Path and branch information
- Array of contents with:
- Name
- Type (file or directory)
- Size (for files)
- Full path
- Total items count
### Get File Content
```typescript
{
"tool": "get_file_content",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"file_path": "src/index.ts",
"branch": "main", // Optional (defaults to default branch)
"start_line": 1, // Optional: starting line (1-based, use negative for from end)
"line_count": 100, // Optional: number of lines to return
"full_content": false // Optional: force full content (default: false)
}
}
```
**Smart Truncation Features:**
- Automatically truncates large files (>50KB) to prevent token overload
- Default line counts based on file type:
- Config files (.yml, .json): 200 lines
- Documentation (.md, .txt): 300 lines
- Code files (.ts, .js, .py): 500 lines
- Log files: Last 100 lines
- Use `start_line: -50` to get last 50 lines (tail functionality)
- Files larger than 1MB require explicit `full_content: true` or line parameters
Returns file content with:
- File path and branch
- File size and encoding
- Content (full or truncated based on parameters)
- Line information (if truncated):
- Total lines in file
- Range of returned lines
- Truncation indicator
- Last modified information (commit, author, date)
Example responses:
```json
// Small file - returns full content
{
"file_path": "package.json",
"branch": "main",
"size": 1234,
"encoding": "utf-8",
"content": "{\n \"name\": \"my-project\",\n ...",
"last_modified": {
"commit_id": "abc123",
"author": "John Doe",
"date": "2025-01-21T10:00:00Z"
}
}
// Large file - automatically truncated
{
"file_path": "src/components/LargeComponent.tsx",
"branch": "main",
"size": 125000,
"encoding": "utf-8",
"content": "... first 500 lines ...",
"line_info": {
"total_lines": 3500,
"returned_lines": {
"start": 1,
"end": 500
},
"truncated": true,
"message": "Showing lines 1-500 of 3500. File size: 122.1KB"
}
}
```
## Development ## Development
- `npm run dev` - Watch mode for development - `npm run dev` - Watch mode for development

View file

@ -1,6 +1,6 @@
{ {
"name": "@nexus2520/bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "0.4.0", "version": "0.5.0",
"description": "MCP server for Bitbucket API integration - supports both Cloud and Server", "description": "MCP server for Bitbucket API integration - supports both Cloud and Server",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View file

@ -0,0 +1,352 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { BitbucketApiClient } from '../utils/api-client.js';
import {
isListDirectoryContentArgs,
isGetFileContentArgs
} from '../types/guards.js';
import {
BitbucketServerDirectoryEntry,
BitbucketCloudDirectoryEntry,
BitbucketCloudFileMetadata
} from '../types/bitbucket.js';
import * as path from 'path';
export class FileHandlers {
// Default lines by file extension
private readonly DEFAULT_LINES_BY_EXT: Record<string, number> = {
'.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(
private apiClient: BitbucketApiClient,
private baseUrl: string
) {}
async handleListDirectoryContent(args: any) {
if (!isListDirectoryContentArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for list_directory_content'
);
}
const { workspace, repository, path: dirPath = '', branch } = args;
try {
let apiPath: string;
let params: any = {};
let response: any;
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<any>('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<any>('get', apiPath);
}
// Format the response
let contents: any[] = [];
let actualBranch = branch;
if (this.apiClient.getIsServer()) {
// Bitbucket Server response
const entries = response.children?.values || [];
contents = entries.map((entry: BitbucketServerDirectoryEntry) => ({
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: BitbucketCloudDirectoryEntry) => ({
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: any) {
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: string;
let fileMetadata: any = {};
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: any = {};
if (branch) {
browseParams.at = `refs/heads/${branch}`;
}
try {
const metadataResponse = await this.apiClient.makeRequest<any>('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: any = {};
if (branch) {
rawParams.at = `refs/heads/${branch}`;
}
const response = await this.apiClient.makeRequest<any>('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<BitbucketCloudFileMetadata>('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<any>('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: any = 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: number;
let endIdx: number;
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: any = {
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: any) {
// 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
private getDefaultLines(filePath: string, fileSize: number): { full: boolean } | { start: number; count: number } {
// 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)
};
}
}

View file

@ -12,6 +12,7 @@ import { BitbucketApiClient } from './utils/api-client.js';
import { PullRequestHandlers } from './handlers/pull-request-handlers.js'; import { PullRequestHandlers } from './handlers/pull-request-handlers.js';
import { BranchHandlers } from './handlers/branch-handlers.js'; import { BranchHandlers } from './handlers/branch-handlers.js';
import { ReviewHandlers } from './handlers/review-handlers.js'; import { ReviewHandlers } from './handlers/review-handlers.js';
import { FileHandlers } from './handlers/file-handlers.js';
import { toolDefinitions } from './tools/definitions.js'; import { toolDefinitions } from './tools/definitions.js';
// Get environment variables // Get environment variables
@ -33,12 +34,13 @@ class BitbucketMCPServer {
private pullRequestHandlers: PullRequestHandlers; private pullRequestHandlers: PullRequestHandlers;
private branchHandlers: BranchHandlers; private branchHandlers: BranchHandlers;
private reviewHandlers: ReviewHandlers; private reviewHandlers: ReviewHandlers;
private fileHandlers: FileHandlers;
constructor() { constructor() {
this.server = new Server( this.server = new Server(
{ {
name: 'bitbucket-mcp-server', name: 'bitbucket-mcp-server',
version: '0.4.0', version: '0.5.0',
}, },
{ {
capabilities: { capabilities: {
@ -63,6 +65,7 @@ class BitbucketMCPServer {
); );
this.branchHandlers = new BranchHandlers(this.apiClient, BITBUCKET_BASE_URL); this.branchHandlers = new BranchHandlers(this.apiClient, BITBUCKET_BASE_URL);
this.reviewHandlers = new ReviewHandlers(this.apiClient, BITBUCKET_USERNAME!); this.reviewHandlers = new ReviewHandlers(this.apiClient, BITBUCKET_USERNAME!);
this.fileHandlers = new FileHandlers(this.apiClient, BITBUCKET_BASE_URL);
this.setupToolHandlers(); this.setupToolHandlers();
@ -117,6 +120,12 @@ class BitbucketMCPServer {
case 'remove_requested_changes': case 'remove_requested_changes':
return this.reviewHandlers.handleRemoveRequestedChanges(request.params.arguments); return this.reviewHandlers.handleRemoveRequestedChanges(request.params.arguments);
// File tools
case 'list_directory_content':
return this.fileHandlers.handleListDirectoryContent(request.params.arguments);
case 'get_file_content':
return this.fileHandlers.handleGetFileContent(request.params.arguments);
default: default:
throw new McpError( throw new McpError(
ErrorCode.MethodNotFound, ErrorCode.MethodNotFound,

View file

@ -416,4 +416,68 @@ export const toolDefinitions = [
required: ['workspace', 'repository', 'branch_name'], required: ['workspace', 'repository', 'branch_name'],
}, },
}, },
{
name: 'list_directory_content',
description: 'List files and directories in a repository path',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
path: {
type: 'string',
description: 'Directory path (optional, defaults to root, e.g., "src/components")',
},
branch: {
type: 'string',
description: 'Branch name (optional, defaults to default branch)',
},
},
required: ['workspace', 'repository'],
},
},
{
name: 'get_file_content',
description: 'Get file content from a repository with smart truncation for large files',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
file_path: {
type: 'string',
description: 'Path to the file (e.g., "src/index.ts")',
},
branch: {
type: 'string',
description: 'Branch name (optional, defaults to default branch)',
},
start_line: {
type: 'number',
description: 'Starting line number (1-based). Use negative for lines from end (optional)',
},
line_count: {
type: 'number',
description: 'Number of lines to return (optional, default varies by file size)',
},
full_content: {
type: 'boolean',
description: 'Force return full content regardless of size (optional, default: false)',
},
},
required: ['workspace', 'repository', 'file_path'],
},
},
]; ];

View file

@ -115,6 +115,17 @@ export interface BitbucketServerBranch {
}; };
} }
// Bitbucket Server Directory Entry
export interface BitbucketServerDirectoryEntry {
path: {
name: string;
toString: string;
};
type: 'FILE' | 'DIRECTORY';
size?: number;
contentId?: string;
}
// Bitbucket Cloud API response types // Bitbucket Cloud API response types
export interface BitbucketCloudPullRequest { export interface BitbucketCloudPullRequest {
id: number; id: number;
@ -195,6 +206,55 @@ export interface BitbucketCloudBranch {
type: string; type: string;
} }
// Bitbucket Cloud Directory Entry
export interface BitbucketCloudDirectoryEntry {
path: string;
type: 'commit_file' | 'commit_directory';
size?: number;
commit?: {
hash: string;
};
links?: {
self: {
href: string;
};
html: {
href: string;
};
};
}
// Bitbucket Cloud File Metadata
export interface BitbucketCloudFileMetadata {
path: string;
size: number;
encoding?: string;
mimetype?: string;
links: {
self: {
href: string;
};
html: {
href: string;
};
download: {
href: string;
};
};
commit?: {
hash: string;
author?: {
raw: string;
user?: {
display_name: string;
account_id: string;
};
};
date?: string;
message?: string;
};
}
// Merge info type for enhanced PR details // Merge info type for enhanced PR details
export interface MergeInfo { export interface MergeInfo {
mergeCommitHash?: string; mergeCommitHash?: string;

View file

@ -202,3 +202,39 @@ export const isGetBranchArgs = (
typeof args.repository === 'string' && typeof args.repository === 'string' &&
typeof args.branch_name === 'string' && typeof args.branch_name === 'string' &&
(args.include_merged_prs === undefined || typeof args.include_merged_prs === 'boolean'); (args.include_merged_prs === undefined || typeof args.include_merged_prs === 'boolean');
export const isListDirectoryContentArgs = (
args: any
): args is {
workspace: string;
repository: string;
path?: string;
branch?: string;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
(args.path === undefined || typeof args.path === 'string') &&
(args.branch === undefined || typeof args.branch === 'string');
export const isGetFileContentArgs = (
args: any
): args is {
workspace: string;
repository: string;
file_path: string;
branch?: string;
start_line?: number;
line_count?: number;
full_content?: boolean;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.file_path === 'string' &&
(args.branch === undefined || typeof args.branch === 'string') &&
(args.start_line === undefined || typeof args.start_line === 'number') &&
(args.line_count === undefined || typeof args.line_count === 'number') &&
(args.full_content === undefined || typeof args.full_content === 'boolean');