diff --git a/README.md b/README.md index df5b6fa..8347d4e 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ An MCP (Model Context Protocol) server that provides tools for interacting with Currently implemented: - `get_pull_request` - Retrieve detailed information about a pull request +- `list_pull_requests` - List pull requests with filters (state, author, pagination) Planned features: - `create_pull_request` - Create new pull requests -- `list_pull_requests` - List pull requests with filters - `update_pull_request` - Update PR details - `merge_pull_request` - Merge pull requests +- `delete_branch` - Delete branches - And more... ## Installation @@ -117,6 +118,27 @@ Returns detailed information about the pull request including: - Links to web UI and diff - And more... +### List Pull Requests + +```typescript +{ + "tool": "list_pull_requests", + "arguments": { + "workspace": "PROJ", // Required - your project key + "repository": "my-repo", + "state": "OPEN", // Optional: OPEN, MERGED, DECLINED, ALL (default: OPEN) + "author": "username", // Optional: filter by author + "limit": 25, // Optional: max results per page (default: 25) + "start": 0 // Optional: pagination start index (default: 0) + } +} +``` + +Returns a paginated list of pull requests with: +- Array of pull requests with same details as get_pull_request +- Total count of matching PRs +- Pagination info (has_more, next_start) + ## Development - `npm run dev` - Watch mode for development diff --git a/src/index.ts b/src/index.ts index 80256a1..9948ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,6 +169,25 @@ const isGetPullRequestArgs = ( typeof args.repository === 'string' && typeof args.pull_request_id === 'number'; +const isListPullRequestsArgs = ( + args: any +): args is { + workspace: string; + repository: string; + state?: string; + author?: string; + limit?: number; + start?: number; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + (args.state === undefined || typeof args.state === 'string') && + (args.author === undefined || typeof args.author === 'string') && + (args.limit === undefined || typeof args.limit === 'number') && + (args.start === undefined || typeof args.start === 'number'); + class BitbucketMCPServer { private server: Server; private axiosInstance: AxiosInstance; @@ -295,7 +314,7 @@ class BitbucketMCPServer { properties: { workspace: { type: 'string', - description: 'Bitbucket workspace/project key (e.g., "JBIZ")', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', }, repository: { type: 'string', @@ -309,26 +328,69 @@ class BitbucketMCPServer { required: ['workspace', 'repository', 'pull_request_id'], }, }, + { + name: 'list_pull_requests', + description: 'List pull requests for a repository with optional filters', + 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")', + }, + state: { + type: 'string', + description: 'Filter by PR state: OPEN, MERGED, DECLINED, ALL (default: OPEN)', + enum: ['OPEN', 'MERGED', 'DECLINED', 'ALL'], + }, + author: { + type: 'string', + description: 'Filter by author username', + }, + limit: { + type: 'number', + description: 'Maximum number of PRs to return (default: 25)', + }, + start: { + type: 'number', + description: 'Start index for pagination (default: 0)', + }, + }, + required: ['workspace', 'repository'], + }, + }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name !== 'get_pull_request') { - throw new McpError( - ErrorCode.MethodNotFound, - `Unknown tool: ${request.params.name}` - ); + switch (request.params.name) { + case 'get_pull_request': + return this.handleGetPullRequest(request.params.arguments); + case 'list_pull_requests': + return this.handleListPullRequests(request.params.arguments); + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); } + }); + } - if (!isGetPullRequestArgs(request.params.arguments)) { - throw new McpError( - ErrorCode.InvalidParams, - 'Invalid arguments for get_pull_request' - ); - } + private async handleGetPullRequest(args: any) { + if (!isGetPullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for get_pull_request' + ); + } - const { workspace, repository, pull_request_id } = request.params.arguments; + const { workspace, repository, pull_request_id } = args; try { // Different API paths for Server vs Cloud @@ -399,7 +461,137 @@ class BitbucketMCPServer { } throw error; } - }); + } + + private async handleListPullRequests(args: any) { + if (!isListPullRequestsArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for list_pull_requests' + ); + } + + const { workspace, repository, state = 'OPEN', author, limit = 25, start = 0 } = args; + + try { + let apiPath: string; + let params: any = {}; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; + params = { + state: state === 'ALL' ? undefined : state, + limit, + start, + }; + if (author) { + params['username'] = author; + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests`; + params = { + state: state === 'ALL' ? undefined : state, + pagelen: limit, + page: Math.floor(start / limit) + 1, + }; + if (author) { + params['q'] = `author.username="${author}"`; + } + } + + console.error(`[DEBUG] Listing PRs from: ${BITBUCKET_BASE_URL}${apiPath}`); + console.error(`[DEBUG] Params:`, params); + + const response = await this.axiosInstance.get(apiPath, { params }); + const data = response.data; + + // Format the response + let pullRequests: any[] = []; + let totalCount = 0; + let nextPageStart = null; + + if (this.isServer) { + // Bitbucket Server response + pullRequests = (data.values || []).map((pr: BitbucketServerPullRequest) => + this.formatServerResponse(pr) + ); + totalCount = data.size || 0; + if (!data.isLastPage && data.nextPageStart !== undefined) { + nextPageStart = data.nextPageStart; + } + } else { + // Bitbucket Cloud response + pullRequests = (data.values || []).map((pr: BitbucketCloudPullRequest) => + this.formatCloudResponse(pr) + ); + totalCount = data.size || 0; + if (data.next) { + nextPageStart = start + limit; + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + pull_requests: pullRequests, + total_count: totalCount, + start, + limit, + has_more: nextPageStart !== null, + next_start: nextPageStart, + }, null, 2), + }, + ], + }; + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const message = error.response?.data?.errors?.[0]?.message || + error.response?.data?.error?.message || + error.response?.data?.message || + error.message; + + console.error(`[DEBUG] API Error: ${status} - ${message}`); + console.error(`[DEBUG] Full error response:`, error.response?.data); + + if (status === 404) { + return { + content: [ + { + type: 'text', + text: `Repository not found: ${workspace}/${repository}`, + }, + ], + isError: true, + }; + } else if (status === 401) { + return { + content: [ + { + type: 'text', + text: `Authentication failed. Please check your ${this.isServer ? 'BITBUCKET_TOKEN' : 'BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD'}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `Bitbucket API error: ${message}`, + }, + ], + isError: true, + }; + } + throw error; + } } async run() {