From 262d1dfd0f964251ad9c618c2aa6ff1207d3b075 Mon Sep 17 00:00:00 2001 From: Nicolas FRADIN Date: Wed, 20 May 2026 18:54:58 +0200 Subject: [PATCH] docs: add implementation plan for missing Bitbucket MCP features 10-task TDD plan covering domain client split, PR/comment/task write operations, and repository/workspace browsing. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../2026-05-20-bitbucket-mcp-features.md | 2202 +++++++++++++++++ 1 file changed, 2202 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md diff --git a/docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md b/docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md new file mode 100644 index 0000000..5a3718a --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md @@ -0,0 +1,2202 @@ +# Bitbucket MCP Server — Missing Features Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the monolithic `bitbucket-client.ts` into domain clients and add write operations for PRs, comments, tasks, and repository/workspace browsing tools. + +**Architecture:** A new `src/clients/base-client.ts` owns the shared Axios instance, initialization guard, and error interceptors. Domain clients (`pull-request-client.ts`, `repository-client.ts`, `comment-client.ts`) each import the base and add their methods. `bitbucket-client.ts` becomes a composition root that exposes a flat API to the router — the router is unchanged structurally. + +**Tech Stack:** TypeScript 5.3, Node 24, Axios, `@modelcontextprotocol/sdk`, Vitest + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/clients/base-client.ts` | Axios instance, init guard, interceptors, formatError | +| Create | `src/clients/pull-request-client.ts` | All PR read + write methods | +| Create | `src/clients/repository-client.ts` | Workspace, repo, branch read methods | +| Create | `src/clients/comment-client.ts` | Comment + task CRUD methods | +| Rewrite | `src/bitbucket-client.ts` | Composition root — instantiates domain clients, delegates | +| Modify | `src/router.ts` | New `switch` cases for all new tools | +| Modify | `src/index.ts` | New tool schema declarations | +| Modify | `tests/unit/router.test.ts` | New mock entries + test cases for new tools | +| Modify | `tests/integration/bitbucket-api.test.ts` | New integration tests | + +--- + +## Task 1: Create `src/clients/base-client.ts` + +Extract shared infrastructure from the current `bitbucket-client.ts` into a reusable base. + +**Files:** +- Create: `src/clients/base-client.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/router.test.ts` — the existing mock will break because `BitbucketClient` will soon delegate, but first just confirm the file doesn't exist yet: + +```bash +ls src/clients/ 2>/dev/null || echo "directory does not exist yet" +``` + +Expected: `directory does not exist yet` + +- [ ] **Step 2: Create the file** + +```typescript +// src/clients/base-client.ts +import axios, { AxiosInstance, AxiosError } from 'axios'; +import { TokenConfigLoader, TokenConfig } from '../config.js'; + +export interface ClientOptions { + timeout?: number; +} + +export class BaseClient { + protected axiosInstance!: AxiosInstance; + private tokenSource: string | null = null; + private _initialized = false; + private _initPromise: Promise | null = null; + + constructor(options: ClientOptions = {}) { + this._initPromise = this._init(options); + } + + private async _init(options: ClientOptions): Promise { + try { + const config = await TokenConfigLoader.load(); + this.tokenSource = config.source; + this.axiosInstance = axios.create({ + baseURL: 'https://api.bitbucket.org/2.0', + headers: { 'Content-Type': 'application/json' }, + auth: { username: config.email, password: config.token }, + timeout: options.timeout || 30000, + maxRedirects: 5, + }); + this.axiosInstance.interceptors.request.use(cfg => { + cfg.headers['User-Agent'] = 'Bitbucket-MCP-Server/1.0.0'; + return cfg; + }); + this.axiosInstance.interceptors.response.use( + r => r, + (error: AxiosError) => this._handleResponseError(error), + ); + this._initialized = true; + } catch (error) { + throw new Error(`Unable to initialize Bitbucket client: ${error}`); + } + } + + protected async ensureInitialized(): Promise { + if (this._initialized) return; + await this._initPromise; + } + + private async _handleResponseError(error: AxiosError): Promise { + const status = error.response?.status; + if (status === 401) { + console.error('🔐 Bitbucket API Authentication Error (401)'); + console.error(` Token source: ${this.tokenSource || 'unknown'}`); + console.error(` URL: ${error.config?.url}`); + } else if (status === 403) { + console.error('🚫 Bitbucket API Forbidden (403)'); + console.error(` URL: ${error.config?.url}`); + } else if (status === 429) { + const retryAfter = error.response?.headers['retry-after']; + const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000; + console.log(`Rate limited. Retrying in ${delay}ms...`); + await this._sleep(delay); + } + throw error; + } + + protected _sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + protected formatError(error: any): string { + if (error?.response?.status) { + return `HTTP ${error.response.status}: ${error.message}`; + } + return error?.message || 'Unknown error'; + } +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors (existing files still compile; new file is not yet imported). + +- [ ] **Step 4: Commit** + +```bash +git add src/clients/base-client.ts +git commit -m "feat: add BaseClient with shared Axios instance and interceptors" +``` + +--- + +## Task 2: Create `src/clients/pull-request-client.ts` + +Migrate all PR methods from `bitbucket-client.ts` and add write methods. + +**Files:** +- Create: `src/clients/pull-request-client.ts` + +- [ ] **Step 1: Create the file** + +```typescript +// src/clients/pull-request-client.ts +import { BaseClient, ClientOptions } from './base-client.js'; + +export interface CreatePROptions { + title: string; + source_branch: string; + destination_branch: string; + description?: string; + reviewers?: string[]; + close_source_branch?: boolean; +} + +export interface UpdatePROptions { + title?: string; + description?: string; + reviewers?: string[]; + destination_branch?: string; +} + +export interface MergePROptions { + merge_strategy?: 'merge_commit' | 'squash' | 'fast_forward'; + commit_message?: string; + close_source_branch?: boolean; +} + +export class PullRequestClient extends BaseClient { + constructor(options: ClientOptions = {}) { + super(options); + } + + async listPullRequests( + workspace: string, + repoSlug: string, + options?: { state?: string; author?: string; reviewer?: string; since?: string }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.state) params.state = options.state; + if (options?.author) params.author = options.author; + if (options?.reviewer) params.reviewer = options.reviewer; + if (options?.since) params.since = options.since; + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests`, + { params }, + ); + return response.data.values || []; + } catch (error) { + throw new Error(`Failed to list pull requests: ${this.formatError(error)}`); + } + } + + async getPullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get pull request: ${this.formatError(error)}`); + } + } + + async getPullRequestActivities( + workspace: string, repoSlug: string, prId: number, + options?: { limit?: number; start?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.limit) params.limit = options.limit; + if (options?.start) params.start = options.start; + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/activity`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get activities: ${this.formatError(error)}`); + } + } + + async getPullRequestChanges( + workspace: string, repoSlug: string, prId: number, + options?: { limit?: number; start?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.limit) params.limit = options.limit; + if (options?.start) params.start = options.start; + const prResponse = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + const sourceHash = prResponse.data.source?.commit?.hash; + const destHash = prResponse.data.destination?.commit?.hash; + if (!sourceHash || !destHash) { + throw new Error('Could not determine source and destination commits'); + } + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/diffstat/${sourceHash}..${destHash}`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get changes: ${this.formatError(error)}`); + } + } + + async getPullRequestCommits( + workspace: string, repoSlug: string, prId: number, + options?: { limit?: number; start?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.limit) params.limit = options.limit; + if (options?.start) params.start = options.start; + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/commits`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get commits: ${this.formatError(error)}`); + } + } + + async getPullRequestDiff( + workspace: string, repoSlug: string, prId: number, + options?: { context?: number; path?: string; whitespace?: string }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.context) params.context = options.context; + if (options?.whitespace) params.whitespace = options.whitespace; + if (options?.path) { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/diff/${options.path}`, + { params, responseType: 'text' }, + ); + return response.data; + } + const prResponse = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + const sourceHash = prResponse.data.source?.commit?.hash; + const destHash = prResponse.data.destination?.commit?.hash; + if (!sourceHash || !destHash) { + throw new Error('Could not determine source and destination commits'); + } + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`, + { params, responseType: 'text' }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get diff: ${this.formatError(error)}`); + } + } + + async getPullRequestPatch(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const prResponse = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + const sourceHash = prResponse.data.source?.commit?.hash; + const destHash = prResponse.data.destination?.commit?.hash; + if (!sourceHash || !destHash) { + throw new Error('Could not determine source and destination commits'); + } + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`, + { responseType: 'text' }, + ); + return { patch: response.data }; + } catch (error) { + throw new Error(`Failed to get patch: ${this.formatError(error)}`); + } + } + + async getPullRequestParticipants(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + return response.data.participants || []; + } catch (error) { + throw new Error(`Failed to get participants: ${this.formatError(error)}`); + } + } + + async getPullRequestReviewers(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + return response.data.reviewers || []; + } catch (error) { + throw new Error(`Failed to get reviewers: ${this.formatError(error)}`); + } + } + + async getPullRequestStatus(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + ); + const pr = response.data; + return { + id: pr.id, title: pr.title, state: pr.state, status: pr.status, + author: pr.author, + source_branch: pr.source?.branch?.name, + destination_branch: pr.destination?.branch?.name, + created_on: pr.created_on, updated_on: pr.updated_on, + closed_on: pr.closed_on, merge_commit: pr.merge_commit, + }; + } catch (error) { + throw new Error(`Failed to get status: ${this.formatError(error)}`); + } + } + + async getPullRequestTasks(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get tasks: ${this.formatError(error)}`); + } + } + + async getPullRequestTaskCount(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`, + ); + return { count: response.data.values?.length || 0 }; + } catch (error) { + throw new Error(`Failed to get task count: ${this.formatError(error)}`); + } + } + + async getFullPullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + { params: { fields: '+*' } }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get full PR: ${this.formatError(error)}`); + } + } + + // ── Write operations ────────────────────────────────────────────────────── + + async createPullRequest(workspace: string, repoSlug: string, opts: CreatePROptions): Promise { + await this.ensureInitialized(); + try { + const body: Record = { + title: opts.title, + source: { branch: { name: opts.source_branch } }, + destination: { branch: { name: opts.destination_branch } }, + }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.close_source_branch !== undefined) body.close_source_branch = opts.close_source_branch; + if (opts.reviewers?.length) { + body.reviewers = opts.reviewers.map(r => ({ uuid: r })); + } + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to create pull request: ${this.formatError(error)}`); + } + } + + async updatePullRequest( + workspace: string, repoSlug: string, prId: number, opts: UpdatePROptions, + ): Promise { + await this.ensureInitialized(); + try { + const body: Record = {}; + if (opts.title !== undefined) body.title = opts.title; + if (opts.description !== undefined) body.description = opts.description; + if (opts.destination_branch !== undefined) { + body.destination = { branch: { name: opts.destination_branch } }; + } + if (opts.reviewers !== undefined) { + body.reviewers = opts.reviewers.map(r => ({ uuid: r })); + } + const response = await this.axiosInstance.put( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to update pull request: ${this.formatError(error)}`); + } + } + + async mergePullRequest( + workspace: string, repoSlug: string, prId: number, opts: MergePROptions = {}, + ): Promise { + await this.ensureInitialized(); + try { + const body: Record = {}; + if (opts.merge_strategy) body.merge_strategy = opts.merge_strategy; + if (opts.commit_message) body.message = opts.commit_message; + if (opts.close_source_branch !== undefined) body.close_source_branch = opts.close_source_branch; + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/merge`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to merge pull request: ${this.formatError(error)}`); + } + } + + async declinePullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/decline`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to decline pull request: ${this.formatError(error)}`); + } + } + + async approvePullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/approve`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to approve pull request: ${this.formatError(error)}`); + } + } + + async unapprovePullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + await this.axiosInstance.delete( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/approve`, + ); + return { success: true }; + } catch (error) { + throw new Error(`Failed to unapprove pull request: ${this.formatError(error)}`); + } + } + + async requestChangesPullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/request-changes`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to request changes: ${this.formatError(error)}`); + } + } + + async removeRequestChangesPullRequest(workspace: string, repoSlug: string, prId: number): Promise { + await this.ensureInitialized(); + try { + await this.axiosInstance.delete( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/request-changes`, + ); + return { success: true }; + } catch (error) { + throw new Error(`Failed to remove request-changes: ${this.formatError(error)}`); + } + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/clients/pull-request-client.ts +git commit -m "feat: add PullRequestClient with read and write methods" +``` + +--- + +## Task 3: Create `src/clients/repository-client.ts` + +**Files:** +- Create: `src/clients/repository-client.ts` + +- [ ] **Step 1: Create the file** + +```typescript +// src/clients/repository-client.ts +import { BaseClient, ClientOptions } from './base-client.js'; + +export class RepositoryClient extends BaseClient { + constructor(options: ClientOptions = {}) { + super(options); + } + + async listWorkspaces(options?: { page?: number; pagelen?: number }): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.page) params.page = options.page; + if (options?.pagelen) params.pagelen = options.pagelen; + const response = await this.axiosInstance.get('/workspaces', { params }); + return response.data; + } catch (error) { + throw new Error(`Failed to list workspaces: ${this.formatError(error)}`); + } + } + + async listRepositories( + workspace: string, + options?: { role?: 'member' | 'contributor' | 'owner'; page?: number; pagelen?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.role) params.role = options.role; + if (options?.page) params.page = options.page; + if (options?.pagelen) params.pagelen = options.pagelen; + const response = await this.axiosInstance.get( + `/repositories/${workspace}`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to list repositories: ${this.formatError(error)}`); + } + } + + async getRepository(workspace: string, repoSlug: string): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get repository: ${this.formatError(error)}`); + } + } + + async listBranches( + workspace: string, + repoSlug: string, + options?: { filter_by_name?: string; page?: number; pagelen?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.filter_by_name) params.q = `name~"${options.filter_by_name}"`; + if (options?.page) params.page = options.page; + if (options?.pagelen) params.pagelen = options.pagelen; + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/refs/branches`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to list branches: ${this.formatError(error)}`); + } + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/clients/repository-client.ts +git commit -m "feat: add RepositoryClient for workspace, repo, and branch browsing" +``` + +--- + +## Task 4: Create `src/clients/comment-client.ts` + +**Files:** +- Create: `src/clients/comment-client.ts` + +- [ ] **Step 1: Create the file** + +```typescript +// src/clients/comment-client.ts +import { BaseClient, ClientOptions } from './base-client.js'; + +export interface AddCommentOptions { + content: string; + inline?: { path: string; to: number }; + parent_id?: number; +} + +export interface CreateTaskOptions { + content: string; + comment_id?: number; +} + +export interface UpdateTaskOptions { + content?: string; + state?: 'RESOLVED' | 'UNRESOLVED'; +} + +export class CommentClient extends BaseClient { + constructor(options: ClientOptions = {}) { + super(options); + } + + async getPullRequestComments( + workspace: string, repoSlug: string, prId: number, + options?: { limit?: number; start?: number }, + ): Promise { + await this.ensureInitialized(); + try { + const params: Record = {}; + if (options?.limit) params.limit = options.limit; + if (options?.start) params.start = options.start; + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, + { params }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get comments: ${this.formatError(error)}`); + } + } + + async getPullRequestComment( + workspace: string, repoSlug: string, prId: number, commentId: number, + ): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to get comment: ${this.formatError(error)}`); + } + } + + async addPullRequestComment( + workspace: string, repoSlug: string, prId: number, opts: AddCommentOptions, + ): Promise { + await this.ensureInitialized(); + try { + const body: Record = { content: { raw: opts.content } }; + if (opts.inline) body.inline = opts.inline; + if (opts.parent_id !== undefined) body.parent = { id: opts.parent_id }; + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to add comment: ${this.formatError(error)}`); + } + } + + async updatePullRequestComment( + workspace: string, repoSlug: string, prId: number, commentId: number, + opts: { content: string }, + ): Promise { + await this.ensureInitialized(); + try { + const response = await this.axiosInstance.put( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`, + { content: { raw: opts.content } }, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to update comment: ${this.formatError(error)}`); + } + } + + async deletePullRequestComment( + workspace: string, repoSlug: string, prId: number, commentId: number, + ): Promise { + await this.ensureInitialized(); + try { + await this.axiosInstance.delete( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`, + ); + return { success: true }; + } catch (error) { + throw new Error(`Failed to delete comment: ${this.formatError(error)}`); + } + } + + async createPullRequestTask( + workspace: string, repoSlug: string, prId: number, opts: CreateTaskOptions, + ): Promise { + await this.ensureInitialized(); + try { + const body: Record = { content: { raw: opts.content } }; + if (opts.comment_id !== undefined) body.comment = { id: opts.comment_id }; + const response = await this.axiosInstance.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to create task: ${this.formatError(error)}`); + } + } + + async updatePullRequestTask( + workspace: string, repoSlug: string, prId: number, taskId: number, + opts: UpdateTaskOptions, + ): Promise { + await this.ensureInitialized(); + try { + const body: Record = {}; + if (opts.content !== undefined) body.content = { raw: opts.content }; + if (opts.state !== undefined) body.state = opts.state; + const response = await this.axiosInstance.put( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks/${taskId}`, + body, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to update task: ${this.formatError(error)}`); + } + } + + async deletePullRequestTask( + workspace: string, repoSlug: string, prId: number, taskId: number, + ): Promise { + await this.ensureInitialized(); + try { + await this.axiosInstance.delete( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks/${taskId}`, + ); + return { success: true }; + } catch (error) { + throw new Error(`Failed to delete task: ${this.formatError(error)}`); + } + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/clients/comment-client.ts +git commit -m "feat: add CommentClient with comment and task CRUD" +``` + +--- + +## Task 5: Rewrite `src/bitbucket-client.ts` as composition root + +Replace the monolithic client with a thin facade that delegates to the domain clients. + +**Files:** +- Rewrite: `src/bitbucket-client.ts` + +- [ ] **Step 1: Replace the file** + +```typescript +// src/bitbucket-client.ts +import { ClientOptions } from './clients/base-client.js'; +import { PullRequestClient, CreatePROptions, UpdatePROptions, MergePROptions } from './clients/pull-request-client.js'; +import { RepositoryClient } from './clients/repository-client.js'; +import { CommentClient, AddCommentOptions, CreateTaskOptions, UpdateTaskOptions } from './clients/comment-client.js'; + +export type { CreatePROptions, UpdatePROptions, MergePROptions }; +export type { AddCommentOptions, CreateTaskOptions, UpdateTaskOptions }; +export type { ClientOptions as BitbucketClientOptions }; + +export class BitbucketClient { + private pr: PullRequestClient; + private repo: RepositoryClient; + private comment: CommentClient; + + constructor(options: ClientOptions = {}) { + this.pr = new PullRequestClient(options); + this.repo = new RepositoryClient(options); + this.comment = new CommentClient(options); + } + + async validateToken(): Promise<{ valid: boolean; message: string }> { + try { + await this.repo.listWorkspaces({ pagelen: 1 }); + return { valid: true, message: 'Token is valid' }; + } catch (error: any) { + const status = error?.response?.status ?? error?.cause?.response?.status; + if (status === 401) return { valid: false, message: 'Token is invalid or expired' }; + if (status === 403) return { valid: false, message: 'Token lacks required permissions' }; + return { valid: false, message: `Token validation failed: ${error?.message || error}` }; + } + } + + // ── Pull Request read ───────────────────────────────────────────────────── + listPullRequests = this.pr.listPullRequests.bind(this.pr); + getPullRequest = this.pr.getPullRequest.bind(this.pr); + getPullRequestActivities = this.pr.getPullRequestActivities.bind(this.pr); + getPullRequestChanges = this.pr.getPullRequestChanges.bind(this.pr); + getPullRequestCommits = this.pr.getPullRequestCommits.bind(this.pr); + getPullRequestDiff = this.pr.getPullRequestDiff.bind(this.pr); + getPullRequestPatch = this.pr.getPullRequestPatch.bind(this.pr); + getPullRequestParticipants = this.pr.getPullRequestParticipants.bind(this.pr); + getPullRequestReviewers = this.pr.getPullRequestReviewers.bind(this.pr); + getPullRequestStatus = this.pr.getPullRequestStatus.bind(this.pr); + getPullRequestTasks = this.pr.getPullRequestTasks.bind(this.pr); + getPullRequestTaskCount = this.pr.getPullRequestTaskCount.bind(this.pr); + getFullPullRequest = this.pr.getFullPullRequest.bind(this.pr); + + // ── Pull Request write ──────────────────────────────────────────────────── + createPullRequest = this.pr.createPullRequest.bind(this.pr); + updatePullRequest = this.pr.updatePullRequest.bind(this.pr); + mergePullRequest = this.pr.mergePullRequest.bind(this.pr); + declinePullRequest = this.pr.declinePullRequest.bind(this.pr); + approvePullRequest = this.pr.approvePullRequest.bind(this.pr); + unapprovePullRequest = this.pr.unapprovePullRequest.bind(this.pr); + requestChangesPullRequest = this.pr.requestChangesPullRequest.bind(this.pr); + removeRequestChangesPullRequest = this.pr.removeRequestChangesPullRequest.bind(this.pr); + + // ── Repository / workspace ──────────────────────────────────────────────── + listWorkspaces = this.repo.listWorkspaces.bind(this.repo); + listRepositories = this.repo.listRepositories.bind(this.repo); + getRepository = this.repo.getRepository.bind(this.repo); + listBranches = this.repo.listBranches.bind(this.repo); + + // ── Comment read ────────────────────────────────────────────────────────── + getPullRequestComments = this.comment.getPullRequestComments.bind(this.comment); + getPullRequestComment = this.comment.getPullRequestComment.bind(this.comment); + + // ── Comment / task write ────────────────────────────────────────────────── + addPullRequestComment = this.comment.addPullRequestComment.bind(this.comment); + updatePullRequestComment = this.comment.updatePullRequestComment.bind(this.comment); + deletePullRequestComment = this.comment.deletePullRequestComment.bind(this.comment); + createPullRequestTask = this.comment.createPullRequestTask.bind(this.comment); + updatePullRequestTask = this.comment.updatePullRequestTask.bind(this.comment); + deletePullRequestTask = this.comment.deletePullRequestTask.bind(this.comment); +} + +export default BitbucketClient; +``` + +- [ ] **Step 2: Run existing unit tests — they must still pass** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/router.test.ts +``` + +Expected: all tests pass (the mock in `router.test.ts` mocks `BitbucketClient` by method name, so the shape change is transparent). + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/bitbucket-client.ts +git commit -m "refactor: replace monolithic BitbucketClient with domain-client composition root" +``` + +--- + +## Task 6: Update router unit test mock and add new test cases + +Add mock entries and tests for all new tools before implementing them in the router. + +**Files:** +- Modify: `tests/unit/router.test.ts` + +- [ ] **Step 1: Add new mock methods to the `vi.mock` block** + +In `tests/unit/router.test.ts`, replace the `vi.mock('../../src/bitbucket-client.js', ...)` block with the expanded version below (which adds all new methods while keeping the existing ones): + +```typescript +vi.mock('../../src/bitbucket-client.js', () => { + return { + BitbucketClient: vi.fn().mockImplementation(() => ({ + // ── existing mocks (unchanged) ──────────────────────────────────────── + listPullRequests: vi.fn().mockResolvedValue([ + { id: 1, title: 'Test PR 1', state: 'OPEN' }, + { id: 2, title: 'Test PR 2', state: 'MERGED' }, + ]), + getPullRequest: vi.fn().mockResolvedValue({ + id: 1, title: 'Test PR', state: 'OPEN', + source: { branch: { name: 'feature' } }, + destination: { branch: { name: 'main' } }, + }), + getPullRequestStatus: vi.fn().mockResolvedValue({ + id: 1, title: 'Test PR', state: 'OPEN', status: 'NORMAL', + }), + getPullRequestActivities: vi.fn().mockResolvedValue({ values: [{ action: 'OPEN' }] }), + getPullRequestChanges: vi.fn().mockResolvedValue({ values: [{ type: 'modified', path: 'src/test.ts' }] }), + getPullRequestComments: vi.fn().mockResolvedValue({ values: [{ id: 1, content: { raw: 'Test comment' } }] }), + getPullRequestComment: vi.fn().mockResolvedValue({ id: 1, content: { raw: 'Test comment' } }), + getPullRequestCommits: vi.fn().mockResolvedValue({ values: [{ hash: 'abc123' }] }), + getPullRequestDiff: vi.fn().mockResolvedValue({ diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@' }), + getPullRequestPatch: vi.fn().mockResolvedValue({ patch: '--- original\n+++ modified' }), + getPullRequestParticipants: vi.fn().mockResolvedValue({ values: [{ user: { display_name: 'Test User' } }] }), + getPullRequestReviewers: vi.fn().mockResolvedValue([{ user: { display_name: 'Reviewer 1' } }]), + getPullRequestTasks: vi.fn().mockResolvedValue({ values: [] }), + getPullRequestTaskCount: vi.fn().mockResolvedValue({ count: 0 }), + getFullPullRequest: vi.fn().mockResolvedValue({ id: 1, title: 'Test PR', description: 'Test description' }), + + // ── new PR write mocks ──────────────────────────────────────────────── + createPullRequest: vi.fn().mockResolvedValue({ id: 10, title: 'New PR', state: 'OPEN' }), + updatePullRequest: vi.fn().mockResolvedValue({ id: 1, title: 'Updated PR', state: 'OPEN' }), + mergePullRequest: vi.fn().mockResolvedValue({ id: 1, state: 'MERGED' }), + declinePullRequest: vi.fn().mockResolvedValue({ id: 1, state: 'DECLINED' }), + approvePullRequest: vi.fn().mockResolvedValue({ approved: true }), + unapprovePullRequest: vi.fn().mockResolvedValue({ success: true }), + requestChangesPullRequest: vi.fn().mockResolvedValue({ state: 'changes_requested' }), + removeRequestChangesPullRequest: vi.fn().mockResolvedValue({ success: true }), + + // ── repository mocks ────────────────────────────────────────────────── + listWorkspaces: vi.fn().mockResolvedValue({ values: [{ slug: 'my-workspace' }] }), + listRepositories: vi.fn().mockResolvedValue({ values: [{ slug: 'my-repo', name: 'My Repo' }] }), + getRepository: vi.fn().mockResolvedValue({ slug: 'my-repo', name: 'My Repo', full_name: 'ws/my-repo' }), + listBranches: vi.fn().mockResolvedValue({ values: [{ name: 'main' }, { name: 'develop' }] }), + + // ── comment / task write mocks ──────────────────────────────────────── + addPullRequestComment: vi.fn().mockResolvedValue({ id: 100, content: { raw: 'New comment' } }), + updatePullRequestComment: vi.fn().mockResolvedValue({ id: 100, content: { raw: 'Updated' } }), + deletePullRequestComment: vi.fn().mockResolvedValue({ success: true }), + createPullRequestTask: vi.fn().mockResolvedValue({ id: 200, content: { raw: 'Task' }, state: 'UNRESOLVED' }), + updatePullRequestTask: vi.fn().mockResolvedValue({ id: 200, state: 'RESOLVED' }), + deletePullRequestTask: vi.fn().mockResolvedValue({ success: true }), + })), + }; +}); +``` + +- [ ] **Step 2: Add test cases for new tools** + +Append these `describe` blocks inside `describe('BitbucketRouter', ...)` in `tests/unit/router.test.ts` (before the closing `}`): + +```typescript + describe('list_workspaces', () => { + it('should return workspaces', async () => { + const result = await router.executeTool('list_workspaces', {}) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('values'); + }); + }); + + describe('list_repositories', () => { + it('should return repositories for valid workspace', async () => { + const result = await router.executeTool('list_repositories', { + workspace: 'test-workspace', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('values'); + }); + + it('should fail without workspace', async () => { + const result = await router.executeTool('list_repositories', {}) as ToolResult; + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameters'); + }); + }); + + describe('get_repository', () => { + it('should return repository metadata', async () => { + const result = await router.executeTool('get_repository', { + workspace: 'test-workspace', + repository: 'test-repo', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('slug'); + }); + }); + + describe('list_branches', () => { + it('should return branches', async () => { + const result = await router.executeTool('list_branches', { + workspace: 'test-workspace', + repository: 'test-repo', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('values'); + }); + }); + + describe('create_pull_request', () => { + it('should create a pull request', async () => { + const result = await router.executeTool('create_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + title: 'My PR', + source_branch: 'feature/x', + destination_branch: 'main', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('id'); + }); + + it('should fail without title', async () => { + const result = await router.executeTool('create_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + source_branch: 'feature/x', + destination_branch: 'main', + }) as ToolResult; + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameters'); + }); + + it('should fail without source_branch', async () => { + const result = await router.executeTool('create_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + title: 'My PR', + destination_branch: 'main', + }) as ToolResult; + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameters'); + }); + }); + + describe('update_pull_request', () => { + it('should update a pull request', async () => { + const result = await router.executeTool('update_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + title: 'Updated', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('id'); + }); + }); + + describe('merge_pull_request', () => { + it('should merge a pull request', async () => { + const result = await router.executeTool('merge_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('state', 'MERGED'); + }); + }); + + describe('decline_pull_request', () => { + it('should decline a pull request', async () => { + const result = await router.executeTool('decline_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('state', 'DECLINED'); + }); + }); + + describe('approve_pull_request', () => { + it('should approve a pull request', async () => { + const result = await router.executeTool('approve_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('unapprove_pull_request', () => { + it('should remove approval', async () => { + const result = await router.executeTool('unapprove_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('request_changes_pull_request', () => { + it('should request changes', async () => { + const result = await router.executeTool('request_changes_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('remove_request_changes_pull_request', () => { + it('should remove request-changes', async () => { + const result = await router.executeTool('remove_request_changes_pull_request', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('add_pull_request_comment', () => { + it('should add a comment', async () => { + const result = await router.executeTool('add_pull_request_comment', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + content: 'Great work!', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('id'); + }); + + it('should fail without content', async () => { + const result = await router.executeTool('add_pull_request_comment', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameters'); + }); + }); + + describe('update_pull_request_comment', () => { + it('should update a comment', async () => { + const result = await router.executeTool('update_pull_request_comment', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + commentId: 100, + content: 'Edited', + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('delete_pull_request_comment', () => { + it('should delete a comment', async () => { + const result = await router.executeTool('delete_pull_request_comment', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + commentId: 100, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('create_pull_request_task', () => { + it('should create a task', async () => { + const result = await router.executeTool('create_pull_request_task', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + content: 'Review section 3', + }) as ToolResult; + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('id'); + }); + + it('should fail without content', async () => { + const result = await router.executeTool('create_pull_request_task', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + }) as ToolResult; + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameters'); + }); + }); + + describe('update_pull_request_task', () => { + it('should update a task', async () => { + const result = await router.executeTool('update_pull_request_task', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + taskId: 200, + state: 'RESOLVED', + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); + + describe('delete_pull_request_task', () => { + it('should delete a task', async () => { + const result = await router.executeTool('delete_pull_request_task', { + workspace: 'test-workspace', + repository: 'test-repo', + pullRequestId: 1, + taskId: 200, + }) as ToolResult; + expect(result.success).toBe(true); + }); + }); +``` + +- [ ] **Step 3: Run the new tests — they must FAIL because router cases don't exist yet** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/router.test.ts 2>&1 | tail -30 +``` + +Expected: failures on the new describe blocks showing `Unknown tool: list_workspaces` etc., confirming TDD setup. + +- [ ] **Step 4: Commit failing tests** + +```bash +git add tests/unit/router.test.ts +git commit -m "test: add failing unit tests for new MCP tools (TDD)" +``` + +--- + +## Task 7: Add new switch cases to `src/router.ts` + +Implement router handlers for all new tools so the unit tests pass. + +**Files:** +- Modify: `src/router.ts` + +- [ ] **Step 1: Add new `switch` cases to `executeTool()`** + +Inside the `switch (toolName)` block in `src/router.ts`, append after the `case 'get_full_pull_request':` case and before `default:`: + +```typescript + case 'list_workspaces': + return this.listWorkspaces(params); + + case 'list_repositories': + return this.listRepositories(params); + + case 'get_repository': + return this.getRepository(params); + + case 'list_branches': + return this.listBranches(params); + + case 'create_pull_request': + return this.createPullRequest(params); + + case 'update_pull_request': + return this.updatePullRequest(params); + + case 'merge_pull_request': + return this.mergePullRequest(params); + + case 'decline_pull_request': + return this.declinePullRequest(params); + + case 'approve_pull_request': + return this.approvePullRequest(params); + + case 'unapprove_pull_request': + return this.unapprovePullRequest(params); + + case 'request_changes_pull_request': + return this.requestChangesPullRequest(params); + + case 'remove_request_changes_pull_request': + return this.removeRequestChangesPullRequest(params); + + case 'add_pull_request_comment': + return this.addPullRequestComment(params); + + case 'update_pull_request_comment': + return this.updatePullRequestComment(params); + + case 'delete_pull_request_comment': + return this.deletePullRequestComment(params); + + case 'create_pull_request_task': + return this.createPullRequestTask(params); + + case 'update_pull_request_task': + return this.updatePullRequestTask(params); + + case 'delete_pull_request_task': + return this.deletePullRequestTask(params); +``` + +- [ ] **Step 2: Add private handler methods** + +Append these methods to the `BitbucketRouter` class in `src/router.ts` (before the final closing `}`): + +```typescript + private async listWorkspaces(params: ToolCallParams): Promise { + try { + const result = await this.client.listWorkspaces({ + page: params.page, + pagelen: params.pagelen, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `List workspaces failed: ${String(error)}` }; + } + } + + private async listRepositories(params: ToolCallParams): Promise { + try { + const { workspace } = this.getDefaultParams(params); + if (!workspace) { + return { success: false, error: 'Missing required parameters: workspace' }; + } + const result = await this.client.listRepositories(workspace, { + role: params.role, + page: params.page, + pagelen: params.pagelen, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `List repositories failed: ${String(error)}` }; + } + } + + private async getRepository(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + if (!workspace || !repoSlug) { + return { success: false, error: 'Missing required parameters: workspace and repository' }; + } + const result = await this.client.getRepository(workspace, repoSlug); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Get repository failed: ${String(error)}` }; + } + } + + private async listBranches(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + if (!workspace || !repoSlug) { + return { success: false, error: 'Missing required parameters: workspace and repository' }; + } + const result = await this.client.listBranches(workspace, repoSlug, { + filter_by_name: params.filter_by_name, + page: params.page, + pagelen: params.pagelen, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `List branches failed: ${String(error)}` }; + } + } + + private async createPullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + if (!workspace || !repoSlug || !params.title || !params.source_branch || !params.destination_branch) { + return { success: false, error: 'Missing required parameters: workspace, repository, title, source_branch, destination_branch' }; + } + const result = await this.client.createPullRequest(workspace, repoSlug, { + title: params.title, + source_branch: params.source_branch, + destination_branch: params.destination_branch, + description: params.description, + reviewers: params.reviewers, + close_source_branch: params.close_source_branch, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Create pull request failed: ${String(error)}` }; + } + } + + private async updatePullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.updatePullRequest(workspace, repoSlug, prId, { + title: params.title, + description: params.description, + reviewers: params.reviewers, + destination_branch: params.destination_branch, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Update pull request failed: ${String(error)}` }; + } + } + + private async mergePullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.mergePullRequest(workspace, repoSlug, prId, { + merge_strategy: params.merge_strategy, + commit_message: params.commit_message, + close_source_branch: params.close_source_branch, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Merge pull request failed: ${String(error)}` }; + } + } + + private async declinePullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.declinePullRequest(workspace, repoSlug, prId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Decline pull request failed: ${String(error)}` }; + } + } + + private async approvePullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.approvePullRequest(workspace, repoSlug, prId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Approve pull request failed: ${String(error)}` }; + } + } + + private async unapprovePullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.unapprovePullRequest(workspace, repoSlug, prId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Unapprove pull request failed: ${String(error)}` }; + } + } + + private async requestChangesPullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.requestChangesPullRequest(workspace, repoSlug, prId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Request changes failed: ${String(error)}` }; + } + } + + private async removeRequestChangesPullRequest(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.removeRequestChangesPullRequest(workspace, repoSlug, prId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Remove request-changes failed: ${String(error)}` }; + } + } + + private async addPullRequestComment(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || !params.content) { + return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, content' }; + } + const opts: any = { content: params.content }; + if (params.inline_path && params.inline_line) { + opts.inline = { path: params.inline_path, to: parseInt(params.inline_line, 10) }; + } + if (params.parent_comment_id) { + opts.parent_id = parseInt(params.parent_comment_id, 10); + } + const result = await this.client.addPullRequestComment(workspace, repoSlug, prId, opts); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Add comment failed: ${String(error)}` }; + } + } + + private async updatePullRequestComment(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + const commentId = parseInt(params.commentId || params.comment_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || isNaN(commentId) || !params.content) { + return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, commentId, content' }; + } + const result = await this.client.updatePullRequestComment(workspace, repoSlug, prId, commentId, { content: params.content }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Update comment failed: ${String(error)}` }; + } + } + + private async deletePullRequestComment(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + const commentId = parseInt(params.commentId || params.comment_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || isNaN(commentId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.deletePullRequestComment(workspace, repoSlug, prId, commentId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Delete comment failed: ${String(error)}` }; + } + } + + private async createPullRequestTask(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || !params.content) { + return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, content' }; + } + const opts: any = { content: params.content }; + if (params.comment_id) opts.comment_id = parseInt(params.comment_id, 10); + const result = await this.client.createPullRequestTask(workspace, repoSlug, prId, opts); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Create task failed: ${String(error)}` }; + } + } + + private async updatePullRequestTask(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + const taskId = parseInt(params.taskId || params.task_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || isNaN(taskId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.updatePullRequestTask(workspace, repoSlug, prId, taskId, { + content: params.content, + state: params.state, + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Update task failed: ${String(error)}` }; + } + } + + private async deletePullRequestTask(params: ToolCallParams): Promise { + try { + const { workspace, repoSlug } = this.getDefaultParams(params); + const prId = parseInt(params.pullRequestId || params.pr_id, 10); + const taskId = parseInt(params.taskId || params.task_id, 10); + if (!workspace || !repoSlug || isNaN(prId) || isNaN(taskId)) { + return { success: false, error: 'Missing required parameters' }; + } + const result = await this.client.deletePullRequestTask(workspace, repoSlug, prId, taskId); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: `Delete task failed: ${String(error)}` }; + } + } +``` + +- [ ] **Step 3: Run ALL unit tests — they must now pass** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/router.test.ts +``` + +Expected: all tests pass, green output. + +- [ ] **Step 4: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/router.ts +git commit -m "feat: add router handlers for all new MCP tools" +``` + +--- + +## Task 8: Declare new tool schemas in `src/index.ts` + +Register every new tool so MCP clients can discover it. + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Add new tool declarations** + +Inside the `ListToolsRequestSchema` handler in `src/index.ts`, append the following entries to the `tools` array (after the `get_full_pull_request` entry, before the closing `]`): + +```typescript + // ── Repository / workspace ──────────────────────────────────────── + { + name: 'list_workspaces', + description: 'List all Bitbucket workspaces the authenticated user belongs to', + inputSchema: { + type: 'object', + properties: { + page: { type: 'integer', description: 'Page number (1-based)' }, + pagelen: { type: 'integer', description: 'Results per page (default 10, max 100)' }, + }, + }, + }, + { + name: 'list_repositories', + description: 'List repositories in a Bitbucket workspace', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + role: { type: 'string', enum: ['member', 'contributor', 'owner'] }, + page: { type: 'integer' }, + pagelen: { type: 'integer' }, + }, + required: ['workspace'], + }, + }, + { + name: 'get_repository', + description: 'Get metadata for a specific Bitbucket repository', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + }, + required: ['workspace', 'repository'], + }, + }, + { + name: 'list_branches', + description: 'List branches in a Bitbucket repository', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + filter_by_name: { type: 'string', description: 'Filter branches whose name contains this string' }, + page: { type: 'integer' }, + pagelen: { type: 'integer' }, + }, + required: ['workspace', 'repository'], + }, + }, + + // ── PR write ────────────────────────────────────────────────────── + { + name: 'create_pull_request', + description: 'Create a new pull request in a Bitbucket repository', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + title: { type: 'string' }, + source_branch: { type: 'string' }, + destination_branch: { type: 'string' }, + description: { type: 'string' }, + reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer account UUIDs' }, + close_source_branch: { type: 'boolean' }, + }, + required: ['workspace', 'repository', 'title', 'source_branch', 'destination_branch'], + }, + }, + { + name: 'update_pull_request', + description: 'Update the title, description, reviewers, or destination branch of a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + title: { type: 'string' }, + description: { type: 'string' }, + reviewers: { type: 'array', items: { type: 'string' } }, + destination_branch: { type: 'string' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'merge_pull_request', + description: 'Merge an open pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + merge_strategy: { type: 'string', enum: ['merge_commit', 'squash', 'fast_forward'] }, + commit_message: { type: 'string' }, + close_source_branch: { type: 'boolean' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'decline_pull_request', + description: 'Decline (close) an open pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'approve_pull_request', + description: 'Approve a pull request as the authenticated user', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'unapprove_pull_request', + description: 'Remove approval from a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'request_changes_pull_request', + description: 'Request changes on a pull request as the authenticated user', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + { + name: 'remove_request_changes_pull_request', + description: 'Remove a request-changes vote from a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId'], + }, + }, + + // ── Comment write ───────────────────────────────────────────────── + { + name: 'add_pull_request_comment', + description: 'Add a comment to a pull request (general or inline on a specific file/line)', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + content: { type: 'string', description: 'Comment text (Markdown supported)' }, + inline_path: { type: 'string', description: 'File path for inline comment' }, + inline_line: { type: 'integer', description: 'Line number for inline comment' }, + parent_comment_id: { type: 'integer', description: 'Parent comment ID to reply to' }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'content'], + }, + }, + { + name: 'update_pull_request_comment', + description: 'Edit the text of an existing pull request comment', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + commentId: { type: 'integer' }, + content: { type: 'string' }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'commentId', 'content'], + }, + }, + { + name: 'delete_pull_request_comment', + description: 'Delete a comment from a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + commentId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'commentId'], + }, + }, + + // ── Task write ──────────────────────────────────────────────────── + { + name: 'create_pull_request_task', + description: 'Create a review task on a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + content: { type: 'string', description: 'Task description text' }, + comment_id: { type: 'integer', description: 'Anchor task to this comment ID' }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'content'], + }, + }, + { + name: 'update_pull_request_task', + description: 'Update a pull request task content or resolve/unresolve it', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + taskId: { type: 'integer' }, + content: { type: 'string' }, + state: { type: 'string', enum: ['RESOLVED', 'UNRESOLVED'] }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'taskId'], + }, + }, + { + name: 'delete_pull_request_task', + description: 'Delete a pull request task', + inputSchema: { + type: 'object', + properties: { + workspace: { type: 'string' }, + repository: { type: 'string' }, + pullRequestId: { type: 'integer' }, + taskId: { type: 'integer' }, + }, + required: ['workspace', 'repository', 'pullRequestId', 'taskId'], + }, + }, +``` + +- [ ] **Step 2: Run all unit tests** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/ +``` + +Expected: all pass. + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/index.ts +git commit -m "feat: register new tool schemas in MCP server (repo browsing + PR/comment/task write)" +``` + +--- + +## Task 9: Add integration tests + +Extend `tests/integration/bitbucket-api.test.ts` with tests for new tools. Read-only tests run unconditionally; write tests are gated on `RUN_WRITE_TESTS=true`. + +**Files:** +- Modify: `tests/integration/bitbucket-api.test.ts` + +- [ ] **Step 1: Append new integration test suites** + +At the end of `tests/integration/bitbucket-api.test.ts` (after the existing `Error Handling` describe block), add: + +```typescript +const RUN_WRITES = process.env.RUN_WRITE_TESTS === 'true'; + +describe('Repository / Workspace (read)', () => { + let client: BitbucketClient; + + beforeAll(() => { + client = new BitbucketClient(); + }); + + it('should list workspaces', async () => { + if (!HAS_TOKEN) return; + const result = await client.listWorkspaces({ pagelen: 10 }); + expect(result).toHaveProperty('values'); + expect(Array.isArray(result.values)).toBe(true); + }); + + it('should list repositories in workspace', async () => { + if (!HAS_TOKEN) return; + const result = await client.listRepositories(WORKSPACE, { pagelen: 10 }); + expect(result).toHaveProperty('values'); + expect(Array.isArray(result.values)).toBe(true); + }); + + it('should get a specific repository', async () => { + if (!HAS_TOKEN) return; + const result = await client.getRepository(WORKSPACE, REPO_SLUG); + expect(result).toHaveProperty('slug'); + expect(result).toHaveProperty('full_name'); + }); + + it('should list branches', async () => { + if (!HAS_TOKEN) return; + const result = await client.listBranches(WORKSPACE, REPO_SLUG, { pagelen: 10 }); + expect(result).toHaveProperty('values'); + expect(Array.isArray(result.values)).toBe(true); + if (result.values.length > 0) { + expect(result.values[0]).toHaveProperty('name'); + } + }); + + it('should filter branches by name', async () => { + if (!HAS_TOKEN) return; + const result = await client.listBranches(WORKSPACE, REPO_SLUG, { filter_by_name: 'main' }); + expect(result).toHaveProperty('values'); + }); +}); + +describe('PR Write Operations (requires RUN_WRITE_TESTS=true)', () => { + let client: BitbucketClient; + let testPrId: number; + + beforeAll(() => { + client = new BitbucketClient(); + }); + + it('should approve a PR', async () => { + if (!HAS_TOKEN || !RUN_WRITES) return; + const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' }); + if (prs.length === 0) { console.log('⚠️ No open PRs, skipping'); return; } + const result = await client.approvePullRequest(WORKSPACE, REPO_SLUG, prs[0].id); + expect(result).toBeDefined(); + }); + + it('should unapprove a PR', async () => { + if (!HAS_TOKEN || !RUN_WRITES) return; + const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' }); + if (prs.length === 0) { console.log('⚠️ No open PRs, skipping'); return; } + const result = await client.unapprovePullRequest(WORKSPACE, REPO_SLUG, prs[0].id); + expect(result).toHaveProperty('success', true); + }); +}); + +describe('Comment / Task Write Operations (requires RUN_WRITE_TESTS=true)', () => { + let client: BitbucketClient; + + beforeAll(() => { + client = new BitbucketClient(); + }); + + it('should add, update, then delete a comment', async () => { + if (!HAS_TOKEN || !RUN_WRITES) return; + const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' }); + if (prs.length === 0) { console.log('⚠️ No open PRs, skipping'); return; } + const prId = prs[0].id; + + const added = await client.addPullRequestComment(WORKSPACE, REPO_SLUG, prId, { content: 'Integration test comment' }); + expect(added).toHaveProperty('id'); + const commentId = added.id; + + const updated = await client.updatePullRequestComment(WORKSPACE, REPO_SLUG, prId, commentId, { content: 'Updated comment' }); + expect(updated).toHaveProperty('id', commentId); + + const deleted = await client.deletePullRequestComment(WORKSPACE, REPO_SLUG, prId, commentId); + expect(deleted).toHaveProperty('success', true); + }); + + it('should create, resolve, then delete a task', async () => { + if (!HAS_TOKEN || !RUN_WRITES) return; + const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' }); + if (prs.length === 0) { console.log('⚠️ No open PRs, skipping'); return; } + const prId = prs[0].id; + + const created = await client.createPullRequestTask(WORKSPACE, REPO_SLUG, prId, { content: 'Integration test task' }); + expect(created).toHaveProperty('id'); + const taskId = created.id; + + const resolved = await client.updatePullRequestTask(WORKSPACE, REPO_SLUG, prId, taskId, { state: 'RESOLVED' }); + expect(resolved).toBeDefined(); + + const deleted = await client.deletePullRequestTask(WORKSPACE, REPO_SLUG, prId, taskId); + expect(deleted).toHaveProperty('success', true); + }); +}); +``` + +- [ ] **Step 2: Run unit tests (integration tests require live credentials — skip here)** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/ +``` + +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/bitbucket-api.test.ts +git commit -m "test: add integration tests for repository browsing and write operations" +``` + +--- + +## Task 10: Final build verification + +Confirm everything compiles and all unit tests are green before calling done. + +**Files:** none created, just verification. + +- [ ] **Step 1: Full unit test run** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/ +``` + +Expected: all tests pass, zero failures. + +- [ ] **Step 2: TypeScript strict check** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Production build** + +```bash +cd /home/nicolas/Projects/AI/bitbucket-mcp && npm run build +``` + +Expected: `dist/` is populated, no compile errors. + +- [ ] **Step 4: Confirm new files in place** + +```bash +ls src/clients/ +``` + +Expected: `base-client.ts pull-request-client.ts repository-client.ts comment-client.ts` + +- [ ] **Step 5: Final commit** + +```bash +git add -A && git commit -m "build: verify full build and test suite after feature implementation" +``` + +--- + +## Summary of new tools added + +| Domain | Tool | +|--------|------| +| Repo | `list_workspaces`, `list_repositories`, `get_repository`, `list_branches` | +| PR write | `create_pull_request`, `update_pull_request`, `merge_pull_request`, `decline_pull_request`, `approve_pull_request`, `unapprove_pull_request`, `request_changes_pull_request`, `remove_request_changes_pull_request` | +| Comment | `add_pull_request_comment`, `update_pull_request_comment`, `delete_pull_request_comment` | +| Task | `create_pull_request_task`, `update_pull_request_task`, `delete_pull_request_task` |