Files
bitbucket-mcp/docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md
Nicolas FRADIN 262d1dfd0f 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) <noreply@anthropic.com>
2026-05-20 18:54:58 +02:00

81 KiB

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:

ls src/clients/ 2>/dev/null || echo "directory does not exist yet"

Expected: directory does not exist yet

  • Step 2: Create the file
// 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<void> | null = null;

  constructor(options: ClientOptions = {}) {
    this._initPromise = this._init(options);
  }

  private async _init(options: ClientOptions): Promise<void> {
    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<void> {
    if (this._initialized) return;
    await this._initPromise;
  }

  private async _handleResponseError(error: AxiosError): Promise<never> {
    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<void> {
    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
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
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

// 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<any[]> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    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<any> {
    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<any> {
    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<any> {
    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<any> {
    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<any> {
    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<any> {
    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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = {
        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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = {};
      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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = {};
      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<any> {
    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<any> {
    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<any> {
    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<any> {
    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<any> {
    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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

// 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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

// 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<any> {
    await this.ensureInitialized();
    try {
      const params: Record<string, any> = {};
      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<any> {
    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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = { 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<any> {
    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<any> {
    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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = { 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<any> {
    await this.ensureInitialized();
    try {
      const body: Record<string, any> = {};
      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<any> {
    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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

// 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
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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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):

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 }):

  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
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
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::

      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 }):

  private async listWorkspaces(params: ToolCallParams): Promise<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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<ToolResult> {
    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
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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 5: Commit
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 ]):

          // ── 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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/

Expected: all pass.

  • Step 3: Verify TypeScript compiles
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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:

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)
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/

Expected: all pass.

  • Step 3: Commit
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
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx vitest run tests/unit/

Expected: all tests pass, zero failures.

  • Step 2: TypeScript strict check
cd /home/nicolas/Projects/AI/bitbucket-mcp && npx tsc --noEmit

Expected: no errors.

  • Step 3: Production build
cd /home/nicolas/Projects/AI/bitbucket-mcp && npm run build

Expected: dist/ is populated, no compile errors.

  • Step 4: Confirm new files in place
ls src/clients/

Expected: base-client.ts pull-request-client.ts repository-client.ts comment-client.ts

  • Step 5: Final commit
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