Skip to main content
Glama
Acendas

Bitbucket MCP Server

by Acendas
server.py32.4 kB
#!/usr/bin/env python3 """ Bitbucket MCP Server - Manage Pull Requests on Bitbucket Cloud Tools: - setup_bitbucket: Configure Bitbucket credentials - get_config_status: Check configuration status - list_workspace_members: List workspace members (for finding reviewers) - get_default_reviewers: Get default reviewers for a repository - create_pull_request: Create a new PR (with default reviewers support) - list_pull_requests: List PRs for a repository - get_pull_request: View details of a specific PR - update_pull_request: Edit an existing PR (including reviewers) - approve_pull_request: Approve a PR - unapprove_pull_request: Remove approval from a PR - request_changes_pull_request: Request changes on a PR - add_pull_request_comment: Add a comment to a PR """ import json import os import stat from pathlib import Path from typing import Optional import httpx from fastmcp import FastMCP # Initialize FastMCP server mcp = FastMCP("bitbucket") # Config file location CONFIG_DIR = Path.home() / ".bitbucket-mcp" CONFIG_FILE = CONFIG_DIR / "config.json" # Bitbucket API base URL BITBUCKET_API = "https://api.bitbucket.org/2.0" def load_config() -> dict: """Load configuration from file.""" if CONFIG_FILE.exists(): with open(CONFIG_FILE, "r") as f: return json.load(f) return {} def save_config(config: dict) -> None: """Save configuration to file with secure permissions.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump(config, f, indent=2) # Set file permissions to 600 (owner read/write only) os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) def get_auth(token: Optional[str] = None, username: Optional[str] = None) -> httpx.BasicAuth: """Get Basic Auth for Bitbucket API (Atlassian API Token).""" config = load_config() auth_token = token or config.get("token") or os.environ.get("BITBUCKET_API_TOKEN") auth_user = username or config.get("username") or os.environ.get("BITBUCKET_USERNAME") if not auth_token or not auth_user: raise ValueError("No Bitbucket credentials configured. Please run setup_bitbucket first.") return httpx.BasicAuth(auth_user, auth_token) def get_workspace(workspace: Optional[str] = None) -> str: """Get workspace from parameter, config, or environment.""" config = load_config() ws = workspace or config.get("workspace") or os.environ.get("BITBUCKET_WORKSPACE") if not ws: raise ValueError("No workspace specified. Please provide workspace parameter or run setup_bitbucket.") return ws @mcp.tool() def setup_bitbucket(workspace: str, username: str, api_token: str) -> str: """ Configure Bitbucket credentials for the MCP server. Args: workspace: Bitbucket workspace slug (e.g., "myworkspace") username: Your Atlassian account email (e.g., "user@example.com") api_token: Atlassian API Token from https://id.atlassian.com/manage-profile/security/api-tokens Returns: Success or error message """ try: # Validate credentials by making a test API call with Basic Auth auth = httpx.BasicAuth(username, api_token) with httpx.Client() as client: # First try to get user info to validate token user_response = client.get( f"{BITBUCKET_API}/user", auth=auth, timeout=30.0 ) if user_response.status_code == 401: return "Error: Invalid credentials. Please check your email and API token." # Then validate workspace access ws_response = client.get( f"{BITBUCKET_API}/workspaces/{workspace}", auth=auth, timeout=30.0 ) if ws_response.status_code == 404: return f"Error: Workspace '{workspace}' not found or you don't have access to it." elif ws_response.status_code == 403: return f"Error: No permission to access workspace '{workspace}'." elif ws_response.status_code != 200: return f"Error: Failed to validate workspace. Status: {ws_response.status_code} - {ws_response.text}" # Save configuration config = { "workspace": workspace, "username": username, "token": api_token } save_config(config) return f"Successfully configured Bitbucket for workspace '{workspace}'. Configuration saved to {CONFIG_FILE}" except httpx.RequestError as e: return f"Error: Failed to connect to Bitbucket API: {str(e)}" except Exception as e: return f"Error: {str(e)}" @mcp.tool() def get_config_status() -> dict: """ Check if Bitbucket is configured and return current status. Returns: Configuration status with workspace info """ config = load_config() env_token = os.environ.get("BITBUCKET_API_TOKEN") env_workspace = os.environ.get("BITBUCKET_WORKSPACE") env_username = os.environ.get("BITBUCKET_USERNAME") has_token = bool(config.get("token") or env_token) has_username = bool(config.get("username") or env_username) configured = has_token and has_username workspace = config.get("workspace") or env_workspace username = config.get("username") or env_username return { "configured": configured, "workspace": workspace, "username": username, "config_file": str(CONFIG_FILE), "source": "config_file" if config.get("token") else ("environment" if env_token else None) } @mcp.tool() def list_workspace_members( workspace: Optional[str] = None, page: int = 1, pagelen: int = 50 ) -> dict: """ List members of a Bitbucket workspace. Use this to find users who can be added as reviewers. Args: workspace: Bitbucket workspace (optional if configured) page: Page number for pagination (default: 1) pagelen: Number of results per page, max 100 (default: 50) Returns: List of workspace members with their UUIDs and display names """ try: ws = get_workspace(workspace) auth = get_auth() params = { "page": page, "pagelen": min(pagelen, 100) } with httpx.Client() as client: response = client.get( f"{BITBUCKET_API}/workspaces/{ws}/members", auth=auth, params=params, timeout=30.0 ) if response.status_code == 200: data = response.json() members = [] for member in data.get("values", []): user = member.get("user", {}) members.append({ "uuid": user.get("uuid"), "account_id": user.get("account_id"), "display_name": user.get("display_name"), "nickname": user.get("nickname") }) return { "success": True, "members": members, "total": data.get("size", len(members)), "page": data.get("page", page), "pagelen": data.get("pagelen", pagelen) } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Workspace '{ws}' not found."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def get_default_reviewers( repo_slug: str, workspace: Optional[str] = None ) -> dict: """ Get the default reviewers configured for a repository. Args: repo_slug: Repository slug (name) workspace: Bitbucket workspace (optional if configured) Returns: List of default reviewers with their UUIDs and display names """ try: ws = get_workspace(workspace) auth = get_auth() with httpx.Client() as client: response = client.get( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/default-reviewers", auth=auth, timeout=30.0 ) if response.status_code == 200: data = response.json() reviewers = [] for reviewer in data.get("values", []): reviewers.append({ "uuid": reviewer.get("uuid"), "account_id": reviewer.get("account_id"), "display_name": reviewer.get("display_name"), "nickname": reviewer.get("nickname") }) return { "success": True, "default_reviewers": reviewers, "count": len(reviewers) } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Repository '{ws}/{repo_slug}' not found."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def create_pull_request( repo_slug: str, title: str, source_branch: str, destination_branch: str = "main", description: str = "", reviewers: Optional[list[str]] = None, use_default_reviewers: bool = True, workspace: Optional[str] = None ) -> dict: """ Create a Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) title: PR title source_branch: Branch to merge from destination_branch: Branch to merge into (default: main) description: PR description (optional) reviewers: List of reviewer UUIDs or account_ids (optional). These are added in addition to default reviewers. use_default_reviewers: Whether to include default reviewers (default: True) workspace: Bitbucket workspace (optional if configured) Returns: PR details including URL and ID, or error message """ try: ws = get_workspace(workspace) auth = get_auth() payload = { "title": title, "source": { "branch": { "name": source_branch } }, "destination": { "branch": { "name": destination_branch } } } if description: payload["description"] = description # Collect reviewers reviewer_list = [] # Add default reviewers if requested if use_default_reviewers: with httpx.Client() as client: default_response = client.get( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/default-reviewers", auth=auth, timeout=30.0 ) if default_response.status_code == 200: default_data = default_response.json() for reviewer in default_data.get("values", []): reviewer_list.append({"uuid": reviewer.get("uuid")}) # Add explicitly specified reviewers if reviewers: for r in reviewers: if r.startswith("{"): reviewer_list.append({"uuid": r}) else: reviewer_list.append({"account_id": r}) # Remove duplicates by uuid seen_uuids = set() unique_reviewers = [] for r in reviewer_list: uuid = r.get("uuid") or r.get("account_id") if uuid and uuid not in seen_uuids: seen_uuids.add(uuid) unique_reviewers.append(r) if unique_reviewers: payload["reviewers"] = unique_reviewers with httpx.Client() as client: response = client.post( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests", auth=auth, json=payload, timeout=30.0 ) if response.status_code == 201: data = response.json() pr_reviewers = [ {"display_name": r.get("display_name"), "uuid": r.get("uuid")} for r in data.get("reviewers", []) ] return { "success": True, "id": data.get("id"), "title": data.get("title"), "url": data.get("links", {}).get("html", {}).get("href"), "state": data.get("state"), "source_branch": source_branch, "destination_branch": destination_branch, "reviewers": pr_reviewers } elif response.status_code == 400: error_data = response.json() error_msg = error_data.get("error", {}).get("message", "Bad request") return {"success": False, "error": error_msg} elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Repository '{ws}/{repo_slug}' not found."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def list_pull_requests( repo_slug: str, state: str = "OPEN", workspace: Optional[str] = None, page: int = 1, pagelen: int = 25 ) -> dict: """ List Pull Requests for a repository on Bitbucket Cloud. Args: repo_slug: Repository slug (name) state: PR state filter - OPEN, MERGED, DECLINED, SUPERSEDED, or ALL (default: OPEN) workspace: Bitbucket workspace (optional if configured) page: Page number for pagination (default: 1) pagelen: Number of results per page, max 50 (default: 25) Returns: List of PRs with their details """ try: ws = get_workspace(workspace) auth = get_auth() params = { "page": page, "pagelen": min(pagelen, 50) } if state.upper() != "ALL": params["state"] = state.upper() with httpx.Client() as client: response = client.get( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests", auth=auth, params=params, timeout=30.0 ) if response.status_code == 200: data = response.json() prs = [] for pr in data.get("values", []): prs.append({ "id": pr.get("id"), "title": pr.get("title"), "state": pr.get("state"), "author": pr.get("author", {}).get("display_name"), "source_branch": pr.get("source", {}).get("branch", {}).get("name"), "destination_branch": pr.get("destination", {}).get("branch", {}).get("name"), "url": pr.get("links", {}).get("html", {}).get("href"), "created_on": pr.get("created_on"), "updated_on": pr.get("updated_on") }) return { "success": True, "pull_requests": prs, "total": data.get("size", len(prs)), "page": data.get("page", page), "pagelen": data.get("pagelen", pagelen) } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Repository '{ws}/{repo_slug}' not found."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def get_pull_request( repo_slug: str, pr_id: int, workspace: Optional[str] = None ) -> dict: """ Get details of a specific Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID workspace: Bitbucket workspace (optional if configured) Returns: Detailed PR information including description, reviewers, and comments count """ try: ws = get_workspace(workspace) auth = get_auth() with httpx.Client() as client: response = client.get( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}", auth=auth, timeout=30.0 ) if response.status_code == 200: pr = response.json() reviewers = [] for reviewer in pr.get("reviewers", []): reviewers.append({ "display_name": reviewer.get("display_name"), "uuid": reviewer.get("uuid"), "approved": reviewer.get("approved", False) }) participants = [] for participant in pr.get("participants", []): participants.append({ "display_name": participant.get("user", {}).get("display_name"), "role": participant.get("role"), "approved": participant.get("approved", False), "state": participant.get("state") }) return { "success": True, "id": pr.get("id"), "title": pr.get("title"), "description": pr.get("description", ""), "state": pr.get("state"), "author": pr.get("author", {}).get("display_name"), "source_branch": pr.get("source", {}).get("branch", {}).get("name"), "destination_branch": pr.get("destination", {}).get("branch", {}).get("name"), "url": pr.get("links", {}).get("html", {}).get("href"), "created_on": pr.get("created_on"), "updated_on": pr.get("updated_on"), "close_source_branch": pr.get("close_source_branch", False), "comment_count": pr.get("comment_count", 0), "task_count": pr.get("task_count", 0), "reviewers": reviewers, "participants": participants, "merge_commit": pr.get("merge_commit", {}).get("hash") if pr.get("merge_commit") else None } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found in '{ws}/{repo_slug}'."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def update_pull_request( repo_slug: str, pr_id: int, title: Optional[str] = None, description: Optional[str] = None, destination_branch: Optional[str] = None, reviewers: Optional[list[str]] = None, close_source_branch: Optional[bool] = None, workspace: Optional[str] = None ) -> dict: """ Update/edit an existing Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID to update title: New PR title (optional) description: New PR description (optional) destination_branch: New destination branch (optional) reviewers: List of reviewer UUIDs or account_ids to set as reviewers (optional). Pass empty list [] to remove all reviewers. close_source_branch: Whether to close source branch on merge (optional) workspace: Bitbucket workspace (optional if configured) Returns: Updated PR details or error message """ try: ws = get_workspace(workspace) auth = get_auth() # Build payload with only provided fields payload = {} if title is not None: payload["title"] = title if description is not None: payload["description"] = description if destination_branch is not None: payload["destination"] = {"branch": {"name": destination_branch}} if reviewers is not None: payload["reviewers"] = [{"uuid": r} if r.startswith("{") else {"account_id": r} for r in reviewers] if close_source_branch is not None: payload["close_source_branch"] = close_source_branch if not payload: return {"success": False, "error": "No update fields provided. Specify at least one of: title, description, destination_branch, reviewers, close_source_branch"} with httpx.Client() as client: response = client.put( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}", auth=auth, json=payload, timeout=30.0 ) if response.status_code == 200: pr = response.json() reviewer_list = [ {"display_name": r.get("display_name"), "uuid": r.get("uuid")} for r in pr.get("reviewers", []) ] return { "success": True, "id": pr.get("id"), "title": pr.get("title"), "description": pr.get("description", ""), "state": pr.get("state"), "source_branch": pr.get("source", {}).get("branch", {}).get("name"), "destination_branch": pr.get("destination", {}).get("branch", {}).get("name"), "url": pr.get("links", {}).get("html", {}).get("href"), "close_source_branch": pr.get("close_source_branch", False), "reviewers": reviewer_list, "updated_on": pr.get("updated_on") } elif response.status_code == 400: error_data = response.json() error_msg = error_data.get("error", {}).get("message", "Bad request") return {"success": False, "error": error_msg} elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found in '{ws}/{repo_slug}'."} elif response.status_code == 403: return {"success": False, "error": "Permission denied. You may not have write access to this PR."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def approve_pull_request( repo_slug: str, pr_id: int, workspace: Optional[str] = None ) -> dict: """ Approve a Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID to approve workspace: Bitbucket workspace (optional if configured) Returns: Approval confirmation or error message """ try: ws = get_workspace(workspace) auth = get_auth() with httpx.Client() as client: response = client.post( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}/approve", auth=auth, timeout=30.0 ) if response.status_code in (200, 201): data = response.json() return { "success": True, "message": f"Pull request #{pr_id} approved successfully", "approved": data.get("approved", True), "user": data.get("user", {}).get("display_name") } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found in '{ws}/{repo_slug}'."} elif response.status_code == 409: return {"success": False, "error": "You have already approved this pull request."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def unapprove_pull_request( repo_slug: str, pr_id: int, workspace: Optional[str] = None ) -> dict: """ Remove approval from a Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID to unapprove workspace: Bitbucket workspace (optional if configured) Returns: Confirmation or error message """ try: ws = get_workspace(workspace) auth = get_auth() with httpx.Client() as client: response = client.delete( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}/approve", auth=auth, timeout=30.0 ) if response.status_code in (200, 204): return { "success": True, "message": f"Approval removed from pull request #{pr_id}" } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found or not previously approved."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def request_changes_pull_request( repo_slug: str, pr_id: int, workspace: Optional[str] = None ) -> dict: """ Request changes on a Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID workspace: Bitbucket workspace (optional if configured) Returns: Confirmation or error message """ try: ws = get_workspace(workspace) auth = get_auth() with httpx.Client() as client: response = client.post( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}/request-changes", auth=auth, timeout=30.0 ) if response.status_code in (200, 201): data = response.json() return { "success": True, "message": f"Changes requested on pull request #{pr_id}", "user": data.get("user", {}).get("display_name") } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found in '{ws}/{repo_slug}'."} elif response.status_code == 409: return {"success": False, "error": "You have already requested changes on this pull request."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} @mcp.tool() def add_pull_request_comment( repo_slug: str, pr_id: int, comment: str, workspace: Optional[str] = None ) -> dict: """ Add a comment to a Pull Request on Bitbucket Cloud. Args: repo_slug: Repository slug (name) pr_id: Pull Request ID comment: Comment text (supports markdown) workspace: Bitbucket workspace (optional if configured) Returns: Comment details or error message """ try: ws = get_workspace(workspace) auth = get_auth() payload = { "content": { "raw": comment } } with httpx.Client() as client: response = client.post( f"{BITBUCKET_API}/repositories/{ws}/{repo_slug}/pullrequests/{pr_id}/comments", auth=auth, json=payload, timeout=30.0 ) if response.status_code in (200, 201): data = response.json() return { "success": True, "id": data.get("id"), "message": f"Comment added to pull request #{pr_id}", "content": data.get("content", {}).get("raw"), "user": data.get("user", {}).get("display_name"), "created_on": data.get("created_on") } elif response.status_code == 401: return {"success": False, "error": "Authentication failed. Please reconfigure with setup_bitbucket."} elif response.status_code == 404: return {"success": False, "error": f"Pull request #{pr_id} not found in '{ws}/{repo_slug}'."} else: return {"success": False, "error": f"API error: {response.status_code} - {response.text}"} except ValueError as e: return {"success": False, "error": str(e)} except httpx.RequestError as e: return {"success": False, "error": f"Connection error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} if __name__ == "__main__": mcp.run()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Acendas/bitbucket-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server