# 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` |