From c6d5b0a6f5525bf9850ff2b34007777a7c5ed2c4 Mon Sep 17 00:00:00 2001 From: pdogra1299 Date: Sat, 21 Jun 2025 17:25:00 +0530 Subject: [PATCH] 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 --- CHANGELOG.md | 22 +++ README.md | 100 ++++++++++ package.json | 2 +- src/handlers/file-handlers.ts | 352 ++++++++++++++++++++++++++++++++++ src/index.ts | 11 +- src/tools/definitions.ts | 64 +++++++ src/types/bitbucket.ts | 60 ++++++ src/types/guards.ts | 36 ++++ 8 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 src/handlers/file-handlers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cb19e73..9f88939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/README.md b/README.md index 069a916..44e0ad7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ An MCP (Model Context Protocol) server that provides tools for interacting with - `delete_branch` - Delete branches (with protection checks) - `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 - `get_pull_request_diff` - Get the diff/changes for 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 - `npm run dev` - Watch mode for development diff --git a/package.json b/package.json index faa0997..a1df7a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "type": "module", "main": "./build/index.js", diff --git a/src/handlers/file-handlers.ts b/src/handlers/file-handlers.ts new file mode 100644 index 0000000..0b514b4 --- /dev/null +++ b/src/handlers/file-handlers.ts @@ -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 = { + '.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('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: 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('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('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: 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) + }; + } +} diff --git a/src/index.ts b/src/index.ts index b0a802f..47d163c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { BitbucketApiClient } from './utils/api-client.js'; import { PullRequestHandlers } from './handlers/pull-request-handlers.js'; import { BranchHandlers } from './handlers/branch-handlers.js'; import { ReviewHandlers } from './handlers/review-handlers.js'; +import { FileHandlers } from './handlers/file-handlers.js'; import { toolDefinitions } from './tools/definitions.js'; // Get environment variables @@ -33,12 +34,13 @@ class BitbucketMCPServer { private pullRequestHandlers: PullRequestHandlers; private branchHandlers: BranchHandlers; private reviewHandlers: ReviewHandlers; + private fileHandlers: FileHandlers; constructor() { this.server = new Server( { name: 'bitbucket-mcp-server', - version: '0.4.0', + version: '0.5.0', }, { capabilities: { @@ -63,6 +65,7 @@ class BitbucketMCPServer { ); this.branchHandlers = new BranchHandlers(this.apiClient, BITBUCKET_BASE_URL); this.reviewHandlers = new ReviewHandlers(this.apiClient, BITBUCKET_USERNAME!); + this.fileHandlers = new FileHandlers(this.apiClient, BITBUCKET_BASE_URL); this.setupToolHandlers(); @@ -117,6 +120,12 @@ class BitbucketMCPServer { case 'remove_requested_changes': 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: throw new McpError( ErrorCode.MethodNotFound, diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index 9de7661..1ea756b 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -416,4 +416,68 @@ export const toolDefinitions = [ 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'], + }, + }, ]; diff --git a/src/types/bitbucket.ts b/src/types/bitbucket.ts index 5942f7c..72ae20c 100644 --- a/src/types/bitbucket.ts +++ b/src/types/bitbucket.ts @@ -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 export interface BitbucketCloudPullRequest { id: number; @@ -195,6 +206,55 @@ export interface BitbucketCloudBranch { 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 export interface MergeInfo { mergeCommitHash?: string; diff --git a/src/types/guards.ts b/src/types/guards.ts index d5d9418..4f059e3 100644 --- a/src/types/guards.ts +++ b/src/types/guards.ts @@ -202,3 +202,39 @@ export const isGetBranchArgs = ( typeof args.repository === 'string' && typeof args.branch_name === 'string' && (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');