UNPKG

mcp-bitbucket

Version:

Model Context Protocol server for Bitbucket integration - manage pull requests, comments, and diffs

653 lines (652 loc) 24.7 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const BITBUCKET_USERNAME = process.env.BITBUCKET_USERNAME; const BITBUCKET_PASSWORD = process.env.BITBUCKET_PASSWORD; const BITBUCKET_URL = process.env.BITBUCKET_URL; // Validate required environment variables if (!BITBUCKET_USERNAME) { throw new Error("BITBUCKET_USERNAME environment variable is required"); } if (!BITBUCKET_PASSWORD) { throw new Error("BITBUCKET_PASSWORD environment variable is required"); } if (!BITBUCKET_URL) { throw new Error("BITBUCKET_URL environment variable is required"); } const WORKSPACE_AND_REPO_PATH = BITBUCKET_URL.replace("https://bitbucket.org/", ""); // Create server instance const server = new McpServer({ name: "mcp-bitbucket", version: "0.0.1", capabilities: { resources: {}, tools: {}, }, }); // Register pull requests listing tool server.tool("list_pull_requests", "List pull requests from a Bitbucket repository with filtering and pagination support", { state: z .enum(["OPEN", "MERGED", "DECLINED"]) .optional() .describe("Filter PRs by state (defaults to OPEN)"), limit: z .number() .min(1) .max(100) .optional() .describe("Maximum number of PRs to return (defaults to 50)"), page: z .number() .min(1) .optional() .describe("Page number for pagination (defaults to 1)"), target_branch: z .string() .optional() .describe("Filter PRs by target/destination branch name"), }, async ({ state, limit, page, target_branch }) => { const prState = state || "OPEN"; const prLimit = limit || 50; const pageNum = page || 1; try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); let url = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests?state=${prState}&pagelen=${prLimit}&page=${pageNum}`; if (target_branch) { url += `&q=destination.branch.name="${target_branch}"`; } const response = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Bitbucket API error: ${response.status} ${response.statusText}`); } const data = await response.json(); const pullRequests = data.values.map((pr) => ({ id: pr.id, title: pr.title, description: pr.description, state: pr.state, author: pr.author.display_name, created_on: pr.created_on, updated_on: pr.updated_on, source_branch: pr.source.branch.name, destination_branch: pr.destination.branch.name, links: { html: pr.links.html.href, }, })); return { content: [ { type: "text", text: JSON.stringify({ total_count: data.size, page: pageNum, page_length: prLimit, next: data.next || null, previous: data.previous || null, pull_requests: pullRequests, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error fetching pull requests: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Register PR with diff tool (by source branch or PR ID) server.tool("get_pr_details", "Get pull request details with commit messages and consolidated PR diff by source branch name or PR ID", { source_branch: z .string() .optional() .describe("Source branch name to find the PR"), pr_id: z.number().optional().describe("Pull request ID"), include_diff: z .boolean() .optional() .describe("Whether to include consolidated PR diff in the response (defaults to false)"), }, async ({ source_branch, pr_id, include_diff }) => { // Validate that either source_branch or pr_id is provided if (!source_branch && !pr_id) { return { content: [ { type: "text", text: "Either source_branch or pr_id must be provided", }, ], isError: true, }; } const shouldIncludeDiff = include_diff === true; // defaults to false try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); let pr; if (pr_id) { // Get PR directly by ID const prUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests/${pr_id}`; const prResponse = await fetch(prUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!prResponse.ok) { throw new Error(`Bitbucket API error: ${prResponse.status} ${prResponse.statusText}`); } pr = await prResponse.json(); } else { // Find the PR by source branch const searchUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests?q=source.branch.name="${source_branch}"`; const searchResponse = await fetch(searchUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!searchResponse.ok) { throw new Error(`Bitbucket API error: ${searchResponse.status} ${searchResponse.statusText}`); } const searchData = await searchResponse.json(); if (searchData.values.length === 0) { return { content: [ { type: "text", text: `No pull request found for source branch: ${source_branch}`, }, ], }; } pr = searchData.values[0]; // Get the first matching PR } // Get commits for this PR const commitsUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests/${pr.id}/commits`; const commitsResponse = await fetch(commitsUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); let commits = []; let prDiff = null; if (commitsResponse.ok) { const commitsData = await commitsResponse.json(); // Get commits without individual diffs commits = commitsData.values.map((commit) => ({ hash: commit.hash, message: commit.message, author: commit.author.user?.display_name || commit.author.raw, date: commit.date, })); // Get consolidated diff for the entire PR if requested if (shouldIncludeDiff) { const prDiffUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests/${pr.id}/diff?ignore_whitespace=true&binary=false`; try { const prDiffResponse = await fetch(prDiffUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "text/plain", }, }); if (prDiffResponse.ok) { prDiff = await prDiffResponse.text(); } else { prDiff = `Error fetching PR diff: ${prDiffResponse.status} ${prDiffResponse.statusText}`; } } catch (error) { prDiff = `Error fetching PR diff: ${error instanceof Error ? error.message : String(error)}`; } } } else { commits = [ `Error fetching commits: ${commitsResponse.status} ${commitsResponse.statusText}`, ]; } const prDetails = { id: pr.id, title: pr.title, // description: pr.description, state: pr.state, author: pr.author.display_name, created_on: pr.created_on, updated_on: pr.updated_on, source_branch: pr.source.branch.name, destination_branch: pr.destination.branch.name, links: { html: pr.links.html.href, }, reviewers: pr.reviewers?.map((reviewer) => ({ display_name: reviewer.display_name, approved: reviewer.approved, })) || [], commits: commits, }; // Add the consolidated diff if requested and available if (shouldIncludeDiff && prDiff !== null) { prDetails.diff = prDiff; } return { content: [ { type: "text", text: JSON.stringify({ search_method: pr_id ? `pr_id: ${pr_id}` : `source_branch: ${source_branch}`, pull_request: prDetails, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error fetching PR with diff: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Register add inline comment to PR tool server.tool("add_pr_inline_comment", "Add an inline comment to a specific line in a pull request", { pr_id: z.number().describe("Pull request ID"), content: z.string().describe("Comment content"), file_path: z.string().describe("Path to the file in the PR"), line: z.number().describe("Line number to comment on"), }, async ({ pr_id, content, file_path, line }) => { try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); const url = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests/${pr_id}/comments`; const commentData = { content: { raw: content, }, inline: { to: line, path: file_path, }, }; const response = await fetch(url, { method: "POST", headers: { Authorization: `Basic ${auth}`, Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(commentData), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Bitbucket API error: ${response.status} ${response.statusText} - ${errorText}`); } const responseData = await response.json(); const commentDetails = { id: responseData.id, content: responseData.content.raw, author: responseData.user.display_name, created_on: responseData.created_on, type: responseData.type, inline: responseData.inline, links: { html: responseData.links?.html?.href, }, }; return { content: [ { type: "text", text: JSON.stringify({ pull_request_id: pr_id, message: "Inline comment added successfully to pull request", comment: commentDetails, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error adding PR inline comment: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Register add comment to PR tool server.tool("add_pr_comment", "Add a general comment to a pull request", { pr_id: z.number().describe("Pull request ID"), content: z.string().describe("Comment content"), }, async ({ pr_id, content }) => { try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); const url = `https://api.bitbucket.org/2.0/repositories/${BITBUCKET_URL.replace("https://bitbucket.org/", "")}/pullrequests/${pr_id}/comments`; const commentData = { content: { raw: content, }, }; const response = await fetch(url, { method: "POST", headers: { Authorization: `Basic ${auth}`, Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(commentData), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Bitbucket API error: ${response.status} ${response.statusText} - ${errorText}`); } const responseData = await response.json(); const commentDetails = { id: responseData.id, content: responseData.content.raw, author: responseData.user.display_name, created_on: responseData.created_on, type: responseData.type, links: { html: responseData.links?.html?.href, }, }; return { content: [ { type: "text", text: JSON.stringify({ pull_request_id: pr_id, message: "Comment added successfully to pull request", comment: commentDetails, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error adding PR comment: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Register view PR comments tool server.tool("view_pr_comments", "View all comments on a pull request by source branch name or PR ID", { source_branch: z .string() .optional() .describe("Source branch name to find the PR"), pr_id: z.number().optional().describe("Pull request ID"), limit: z .number() .min(1) .max(100) .optional() .describe("Maximum number of comments to return (defaults to 50)"), page: z .number() .min(1) .optional() .describe("Page number for pagination (defaults to 1)"), }, async ({ source_branch, pr_id, limit, page }) => { // Validate that either source_branch or pr_id is provided if (!source_branch && !pr_id) { return { content: [ { type: "text", text: "Either source_branch or pr_id must be provided", }, ], isError: true, }; } const commentLimit = limit || 50; const pageNum = page || 1; try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); let targetPrId = pr_id; // If we need to find PR by source branch if (!targetPrId && source_branch) { const searchUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests?q=source.branch.name="${source_branch}"`; const searchResponse = await fetch(searchUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!searchResponse.ok) { throw new Error(`Bitbucket API error: ${searchResponse.status} ${searchResponse.statusText}`); } const searchData = await searchResponse.json(); if (searchData.values.length === 0) { return { content: [ { type: "text", text: `No pull request found for source branch: ${source_branch}`, }, ], }; } targetPrId = searchData.values[0].id; } // Get comments for the PR let commentsUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pullrequests/${targetPrId}/comments?pagelen=${commentLimit}&page=${pageNum}&sort=created_on`; const commentsResponse = await fetch(commentsUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!commentsResponse.ok) { throw new Error(`Bitbucket API error: ${commentsResponse.status} ${commentsResponse.statusText}`); } const commentsData = await commentsResponse.json(); const comments = commentsData.values.map((comment) => ({ id: comment.id, content: comment.content.raw, author: comment.user.display_name, created_on: comment.created_on, updated_on: comment.updated_on, type: comment.type, // "pullrequest_comment" for general comments, "pullrequest_inline_comment" for inline inline: comment.inline || null, // Contains file path and line info for inline comments links: { html: comment.links?.html?.href, }, })); return { content: [ { type: "text", text: JSON.stringify({ search_method: pr_id ? `pr_id: ${pr_id}` : `source_branch: ${source_branch}`, pull_request_id: targetPrId, total_count: commentsData.size, page: pageNum, page_length: commentLimit, next: commentsData.next || null, previous: commentsData.previous || null, comments: comments, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error fetching PR comments: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Register list pipelines tool server.tool("list_pipelines", "List pipelines from a Bitbucket repository with filtering and pagination support", { state: z .enum([ "IN_PROGRESS", "SUCCESSFUL", "FAILED", "STOPPED", "SKIPPED", "PENDING", "ERROR", ]) .optional() .describe("Filter pipelines by state"), limit: z .number() .min(1) .max(100) .optional() .describe("Maximum number of pipelines to return (defaults to 10)"), page: z .number() .min(1) .optional() .describe("Page number for pagination (defaults to 1)"), target_branch: z .string() .optional() .describe("Filter pipelines by target branch name"), }, async ({ state, limit, page, target_branch }) => { const pipelineLimit = limit || 10; const pageNum = page || 1; const sortOrder = "-created_on"; try { const auth = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}`).toString("base64"); let url = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/pipelines/?pagelen=${pipelineLimit}&page=${pageNum}&sort=${sortOrder}`; if (state) { url += `&state=${state}`; } if (target_branch) { url += `&target.ref_name=${target_branch}`; } const response = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Bitbucket API error: ${response.status} ${response.statusText}`); } const data = await response.json(); const pipelines = await Promise.all(data.values.map(async (pipeline) => { let commitMessage = pipeline.target?.commit?.message; // If commit message is not available, fetch it using the commit hash if (!commitMessage && pipeline.target?.commit?.hash) { try { const commitUrl = `https://api.bitbucket.org/2.0/repositories/${WORKSPACE_AND_REPO_PATH}/commit/${pipeline.target.commit.hash}`; const commitResponse = await fetch(commitUrl, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json", }, }); if (commitResponse.ok) { const commitData = await commitResponse.json(); commitMessage = commitData.message; // Update the target commit message if (pipeline.target && pipeline.target.commit) { pipeline.target.commit.message = commitMessage; } } } catch (error) { // Ignore errors fetching commit details } } // Extract PR ID from commit message if it follows the format #pr_id const prIdMatch = commitMessage?.match(/#(\d+)/); const pr_id = prIdMatch ? parseInt(prIdMatch[1]) : null; return { pr_id: pr_id, // uuid: pipeline.uuid, // build_number: pipeline.build_number, state: pipeline.state, created_on: pipeline.created_on, completed_on: pipeline.completed_on, run_number: pipeline.run_number, duration_in_seconds: pipeline.duration_in_seconds, target: pipeline.target, // trigger: pipeline.trigger, // links: pipeline.links, }; })); return { content: [ { type: "text", text: JSON.stringify({ total_count: data.size, page: pageNum, page_length: pipelineLimit, next: data.next || null, previous: data.previous || null, pipelines: pipelines, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error fetching pipelines: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("MCP Bitbucket server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });