feat: Add code snippet support and suggestions to add_comment tool

- Added code_snippet parameter to find line numbers automatically using code text
- Added search_context parameter with before/after arrays for disambiguation
- Added match_strategy parameter (strict/best) for handling multiple matches
- Added suggestion and suggestion_end_line parameters for code suggestions
- Created suggestion-formatter utility for formatting suggestion comments
- Enhanced tool definitions with clearer descriptions and examples
- Updated README with comprehensive usage guide and decision flow
- Removed all debug information from responses for production readiness
- Added detailed error responses when multiple code matches are found
- Improved type definitions and guards for new parameters

This makes the add_comment tool more intelligent and user-friendly, especially for AI-powered code review scenarios.
This commit is contained in:
pdogra1299 2025-06-27 02:26:08 +05:30
parent d649a116fe
commit f602840c97
10 changed files with 746 additions and 73 deletions

View file

@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2025-01-26
### Added
- **Code snippet support in `add_comment` tool**:
- Added `code_snippet` parameter to find line numbers automatically using code text
- Added `search_context` parameter with `before` and `after` arrays to disambiguate multiple matches
- Added `match_strategy` parameter with options:
- `"strict"` (default): Fails with detailed error when multiple matches found
- `"best"`: Auto-selects the highest confidence match
- Returns detailed error with all occurrences when multiple matches found in strict mode
- Particularly useful for AI-powered code review tools that analyze diffs
- Created comprehensive line matching algorithm that:
- Parses diffs to find exact code snippets
- Calculates confidence scores based on context matching
- Handles added, removed, and context lines appropriately
### Changed
- Enhanced `add_comment` tool to resolve line numbers from code snippets when `line_number` is not provided
- Improved error messages to include preview and suggestions for resolving ambiguous matches
## [0.8.0] - 2025-01-26
### Added
- **Code suggestions support in `add_comment` tool**:
- Added `suggestion` parameter to add code suggestions in comments
- Added `suggestion_end_line` parameter for multi-line suggestions
- Suggestions are formatted using GitHub-style markdown ````suggestion` blocks
- Works with both single-line and multi-line code replacements
- Requires `file_path` and `line_number` to be specified when using suggestions
- Compatible with both Bitbucket Cloud and Server
- Created `suggestion-formatter.ts` utility for formatting suggestion comments
### Changed
- Enhanced `add_comment` tool to validate suggestion requirements
- Updated tool response to indicate when a comment contains a suggestion
## [0.7.0] - 2025-01-26 ## [0.7.0] - 2025-01-26
### Added ### Added

207
README.md
View file

@ -271,9 +271,9 @@ Returns a paginated list of pull requests with:
### Add Comment ### Add Comment
Add general comments, reply to existing comments, or add inline comments on specific lines of code: Add a comment to a pull request, either as a general comment or inline on specific code:
```typescript ```javascript
// General comment // General comment
{ {
"tool": "add_comment", "tool": "add_comment",
@ -281,37 +281,150 @@ Add general comments, reply to existing comments, or add inline comments on spec
"workspace": "PROJ", "workspace": "PROJ",
"repository": "my-repo", "repository": "my-repo",
"pull_request_id": 123, "pull_request_id": 123,
"comment_text": "Great work! Just one small suggestion..." "comment_text": "Great work on this PR!"
} }
} }
// Reply to an existing comment // Inline comment on specific line
{ {
"tool": "add_comment", "tool": "add_comment",
"arguments": { "arguments": {
"workspace": "PROJ", "workspace": "PROJ",
"repository": "my-repo", "repository": "my-repo",
"pull_request_id": 123, "pull_request_id": 123,
"comment_text": "Thanks for the feedback! I've updated the code.", "comment_text": "Consider extracting this into a separate function",
"parent_comment_id": 456 // ID of the comment you're replying to "file_path": "src/utils/helpers.js",
}
}
// Inline comment on specific code
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "This variable should be renamed for clarity",
"file_path": "src/main.js",
"line_number": 42, "line_number": 42,
"line_type": "ADDED" // ADDED, REMOVED, or CONTEXT "line_type": "CONTEXT" // ADDED, REMOVED, or CONTEXT
}
}
// Reply to existing comment
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "I agree with this suggestion",
"parent_comment_id": 456
}
}
// Add comment with code suggestion (single line)
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "This variable name could be more descriptive.",
"file_path": "src/utils/helpers.js",
"line_number": 42,
"line_type": "CONTEXT",
"suggestion": "const userAuthenticationToken = token;"
}
}
// Add comment with multi-line code suggestion
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "This function could be simplified using array methods.",
"file_path": "src/utils/calculations.js",
"line_number": 50,
"suggestion_end_line": 55,
"line_type": "CONTEXT",
"suggestion": "function calculateTotal(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"
} }
} }
``` ```
The suggestion feature formats comments using GitHub-style markdown suggestion blocks that Bitbucket can render. When adding a suggestion:
- `suggestion` is required and contains the replacement code
- `file_path` and `line_number` are required when using suggestions
- `suggestion_end_line` is optional and used for multi-line suggestions (defaults to `line_number`)
- The comment will be formatted with a ````suggestion` markdown block that may be applicable in the Bitbucket UI
### Using Code Snippets Instead of Line Numbers
The `add_comment` tool now supports finding line numbers automatically using code snippets. This is especially useful when AI tools analyze diffs and may struggle with exact line numbers:
```javascript
// Add comment using code snippet
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "This variable name could be more descriptive",
"file_path": "src/components/Button.res",
"code_snippet": "let isDisabled = false",
"search_context": {
"before": ["let onClick = () => {"],
"after": ["setLoading(true)"]
}
}
}
// Handle multiple matches with strategy
{
"tool": "add_comment",
"arguments": {
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "Consider extracting this",
"file_path": "src/utils/helpers.js",
"code_snippet": "return result;",
"search_context": {
"before": ["const result = calculate();"],
"after": ["}"]
},
"match_strategy": "best" // Auto-select highest confidence match
}
}
```
**Code Snippet Parameters:**
- `code_snippet`: The exact code line to find (alternative to `line_number`)
- `search_context`: Optional context to disambiguate multiple matches
- `before`: Array of lines that should appear before the target
- `after`: Array of lines that should appear after the target
- `match_strategy`: How to handle multiple matches
- `"strict"` (default): Fail with error showing all matches
- `"best"`: Auto-select the highest confidence match
**Error Response for Multiple Matches (strict mode):**
```json
{
"error": {
"code": "MULTIPLE_MATCHES_FOUND",
"message": "Code snippet 'return result;' found in 3 locations",
"occurrences": [
{
"line_number": 42,
"file_path": "src/utils/helpers.js",
"preview": " const result = calculate();\n> return result;\n}",
"confidence": 0.9,
"line_type": "ADDED"
},
// ... more matches
],
"suggestion": "To resolve, either:\n1. Add more context...\n2. Use match_strategy: 'best'...\n3. Use line_number directly"
}
}
```
This feature is particularly useful for:
- AI-powered code review tools that analyze diffs
- Scripts that automatically add comments based on code patterns
- Avoiding line number confusion in large diffs
**Note on comment replies:** **Note on comment replies:**
- Use `parent_comment_id` to reply to any comment (general or inline) - Use `parent_comment_id` to reply to any comment (general or inline)
- In `get_pull_request` responses: - In `get_pull_request` responses:
@ -327,6 +440,62 @@ Add general comments, reply to existing comments, or add inline comments on spec
- `REMOVED` - For deleted lines (red in diff) - `REMOVED` - For deleted lines (red in diff)
- `CONTEXT` - For unchanged context lines - `CONTEXT` - For unchanged context lines
#### Add Comment - Complete Usage Guide
The `add_comment` tool supports multiple scenarios. Here's when and how to use each approach:
**1. General PR Comments (No file/line)**
- Use when: Making overall feedback about the PR
- Required params: `comment_text` only
- Example: "LGTM!", "Please update the documentation"
**2. Reply to Existing Comments**
- Use when: Continuing a conversation thread
- Required params: `comment_text`, `parent_comment_id`
- Works for both general and inline comment replies
**3. Inline Comments with Line Number**
- Use when: You know the exact line number from the diff
- Required params: `comment_text`, `file_path`, `line_number`
- Optional: `line_type` (defaults to CONTEXT)
**4. Inline Comments with Code Snippet**
- Use when: You have the code but not the line number (common for AI tools)
- Required params: `comment_text`, `file_path`, `code_snippet`
- The tool will automatically find the line number
- Add `search_context` if the code appears multiple times
- Use `match_strategy: "best"` to auto-select when multiple matches exist
**5. Code Suggestions**
- Use when: Proposing specific code changes
- Required params: `comment_text`, `file_path`, `line_number`, `suggestion`
- For multi-line: also add `suggestion_end_line`
- Creates applicable suggestion blocks in Bitbucket UI
**Decision Flow for AI/Automated Tools:**
```
1. Do you want to suggest code changes?
→ Use suggestion with line_number
2. Do you have the exact line number?
→ Use line_number directly
3. Do you have the code snippet but not line number?
→ Use code_snippet (add search_context if needed)
4. Is it a general comment about the PR?
→ Use comment_text only
5. Are you replying to another comment?
→ Add parent_comment_id
```
**Common Pitfalls to Avoid:**
- Don't use both `line_number` and `code_snippet` - pick one
- Suggestions always need `file_path` and `line_number`
- Code snippets must match exactly (including whitespace)
- REMOVED lines reference the source file, ADDED/CONTEXT reference the destination
### Merge Pull Request ### Merge Pull Request
```typescript ```typescript

12
package-lock.json generated
View file

@ -1,16 +1,16 @@
{ {
"name": "@nexus2520/bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "0.7.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@nexus2520/bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "0.7.0", "version": "0.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.9.0", "axios": "^1.10.0",
"minimatch": "^9.0.3" "minimatch": "^9.0.3"
}, },
"bin": { "bin": {
@ -94,9 +94,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.9.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@nexus2520/bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "0.7.0", "version": "0.9.0",
"description": "MCP server for Bitbucket API integration - supports both Cloud and Server", "description": "MCP server for Bitbucket API integration - supports both Cloud and Server",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
@ -44,7 +44,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.9.0", "axios": "^1.10.0",
"minimatch": "^9.0.3" "minimatch": "^9.0.3"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,8 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { BitbucketApiClient } from '../utils/api-client.js'; import { BitbucketApiClient } from '../utils/api-client.js';
import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js'; import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js';
import { formatSuggestionComment } from '../utils/suggestion-formatter.js';
import { DiffParser } from '../utils/diff-parser.js';
import { import {
BitbucketServerPullRequest, BitbucketServerPullRequest,
BitbucketCloudPullRequest, BitbucketCloudPullRequest,
@ -9,7 +11,9 @@ import {
BitbucketCloudComment, BitbucketCloudComment,
BitbucketCloudFileChange, BitbucketCloudFileChange,
FormattedComment, FormattedComment,
FormattedFileChange FormattedFileChange,
CodeMatch,
MultipleMatchesError
} from '../types/bitbucket.js'; } from '../types/bitbucket.js';
import { import {
isGetPullRequestArgs, isGetPullRequestArgs,
@ -27,6 +31,43 @@ export class PullRequestHandlers {
private username: string private username: string
) {} ) {}
private async getFilteredPullRequestDiff(
workspace: string,
repository: string,
pullRequestId: number,
filePath: string,
contextLines: number = 3
): Promise<string> {
let apiPath: string;
let config: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/diff`;
config.params = { contextLines };
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diff`;
config.params = { context: contextLines };
}
config.headers = { 'Accept': 'text/plain' };
const rawDiff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config);
const diffParser = new DiffParser();
const sections = diffParser.parseDiffIntoSections(rawDiff);
const filterOptions = {
filePath: filePath
};
const filteredResult = diffParser.filterSections(sections, filterOptions);
const filteredDiff = diffParser.reconstructDiff(filteredResult.sections);
return filteredDiff;
}
async handleGetPullRequest(args: any) { async handleGetPullRequest(args: any) {
if (!isGetPullRequestArgs(args)) { if (!isGetPullRequestArgs(args)) {
throw new McpError( throw new McpError(
@ -38,7 +79,6 @@ export class PullRequestHandlers {
const { workspace, repository, pull_request_id } = args; const { workspace, repository, pull_request_id } = args;
try { try {
// Different API paths for Server vs Cloud
const apiPath = this.apiClient.getIsServer() const apiPath = this.apiClient.getIsServer()
? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`
: `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`;
@ -47,10 +87,8 @@ export class PullRequestHandlers {
let mergeInfo: MergeInfo = {}; let mergeInfo: MergeInfo = {};
// For Bitbucket Server, fetch additional merge information if PR is merged
if (this.apiClient.getIsServer() && pr.state === 'MERGED') { if (this.apiClient.getIsServer() && pr.state === 'MERGED') {
try { try {
// Try to get activities to find merge information
const activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`; const activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`;
const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, { const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, {
params: { limit: 100 } params: { limit: 100 }
@ -64,25 +102,21 @@ export class PullRequestHandlers {
mergeInfo.mergedBy = mergeActivity.user?.displayName || null; mergeInfo.mergedBy = mergeActivity.user?.displayName || null;
mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString(); mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString();
// Try to get commit message if we have the hash
if (mergeActivity.commit?.id) { if (mergeActivity.commit?.id) {
try { try {
const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`; const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`;
const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath); const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath);
mergeInfo.mergeCommitMessage = commitResponse.message || null; mergeInfo.mergeCommitMessage = commitResponse.message || null;
} catch (commitError) { } catch (commitError) {
// If we can't get the commit message, continue without it
console.error('Failed to fetch merge commit message:', commitError); console.error('Failed to fetch merge commit message:', commitError);
} }
} }
} }
} catch (activitiesError) { } catch (activitiesError) {
// If we can't get activities, continue without merge info
console.error('Failed to fetch PR activities:', activitiesError); console.error('Failed to fetch PR activities:', activitiesError);
} }
} }
// Fetch comments and file changes in parallel
let comments: FormattedComment[] = []; let comments: FormattedComment[] = [];
let activeCommentCount = 0; let activeCommentCount = 0;
let totalCommentCount = 0; let totalCommentCount = 0;
@ -101,16 +135,13 @@ export class PullRequestHandlers {
fileChanges = fileChangesResult.fileChanges; fileChanges = fileChangesResult.fileChanges;
fileChangesSummary = fileChangesResult.summary; fileChangesSummary = fileChangesResult.summary;
} catch (error) { } catch (error) {
// Log error but continue with PR data
console.error('Failed to fetch additional PR data:', error); console.error('Failed to fetch additional PR data:', error);
} }
// Format the response based on server type
const formattedResponse = this.apiClient.getIsServer() const formattedResponse = this.apiClient.getIsServer()
? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl) ? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl)
: formatCloudResponse(pr as BitbucketCloudPullRequest); : formatCloudResponse(pr as BitbucketCloudPullRequest);
// Add comments and file changes to the response
const enhancedResponse = { const enhancedResponse = {
...formattedResponse, ...formattedResponse,
active_comments: comments, active_comments: comments,
@ -174,7 +205,6 @@ export class PullRequestHandlers {
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params });
// Format the response
let pullRequests: any[] = []; let pullRequests: any[] = [];
let totalCount = 0; let totalCount = 0;
let nextPageStart = null; let nextPageStart = null;
@ -386,10 +416,62 @@ export class PullRequestHandlers {
); );
} }
const { workspace, repository, pull_request_id, comment_text, parent_comment_id, file_path, line_number, line_type } = args; let {
workspace,
repository,
pull_request_id,
comment_text,
parent_comment_id,
file_path,
line_number,
line_type,
suggestion,
suggestion_end_line,
code_snippet,
search_context,
match_strategy = 'strict'
} = args;
let sequentialPosition: number | undefined;
if (code_snippet && !line_number && file_path) {
try {
const resolved = await this.resolveLineFromCode(
workspace,
repository,
pull_request_id,
file_path,
code_snippet,
search_context,
match_strategy
);
line_number = resolved.line_number;
line_type = resolved.line_type;
sequentialPosition = resolved.sequential_position;
} catch (error) {
throw error;
}
}
if (suggestion && (!file_path || !line_number)) {
throw new McpError(
ErrorCode.InvalidParams,
'Suggestions require file_path and line_number to be specified'
);
}
const isInlineComment = file_path !== undefined && line_number !== undefined; const isInlineComment = file_path !== undefined && line_number !== undefined;
let finalCommentText = comment_text;
if (suggestion) {
finalCommentText = formatSuggestionComment(
comment_text,
suggestion,
line_number,
suggestion_end_line || line_number
);
}
try { try {
let apiPath: string; let apiPath: string;
let requestBody: any; let requestBody: any;
@ -398,7 +480,7 @@ export class PullRequestHandlers {
// Bitbucket Server API // Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`; apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`;
requestBody = { requestBody = {
text: comment_text text: finalCommentText
}; };
if (parent_comment_id !== undefined) { if (parent_comment_id !== undefined) {
@ -408,18 +490,19 @@ export class PullRequestHandlers {
if (isInlineComment) { if (isInlineComment) {
requestBody.anchor = { requestBody.anchor = {
line: line_number, line: line_number,
lineType: line_type || 'CONTEXT', lineType: line_type || 'CONTEXT',
fileType: line_type === 'REMOVED' ? 'FROM' : 'TO', fileType: line_type === 'REMOVED' ? 'FROM' : 'TO',
path: file_path, path: file_path,
diffType: 'EFFECTIVE' diffType: 'EFFECTIVE'
}; };
} }
} else { } else {
// Bitbucket Cloud API // Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`; apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`;
requestBody = { requestBody = {
content: { content: {
raw: comment_text raw: finalCommentText
} }
}; };
@ -437,12 +520,16 @@ export class PullRequestHandlers {
const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody); const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
const responseMessage = suggestion
? 'Comment with code suggestion added successfully'
: (isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully');
return { return {
content: [ content: [
{ {
type: 'text', type: 'text',
text: JSON.stringify({ text: JSON.stringify({
message: isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully', message: responseMessage,
comment: { comment: {
id: comment.id, id: comment.id,
text: this.apiClient.getIsServer() ? comment.text : comment.content.raw, text: this.apiClient.getIsServer() ? comment.text : comment.content.raw,
@ -450,7 +537,9 @@ export class PullRequestHandlers {
created_on: this.apiClient.getIsServer() ? new Date(comment.createdDate).toLocaleString() : comment.created_on, created_on: this.apiClient.getIsServer() ? new Date(comment.createdDate).toLocaleString() : comment.created_on,
file_path: isInlineComment ? file_path : undefined, file_path: isInlineComment ? file_path : undefined,
line_number: isInlineComment ? line_number : undefined, line_number: isInlineComment ? line_number : undefined,
line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined,
has_suggestion: !!suggestion,
suggestion_lines: suggestion ? (suggestion_end_line ? `${line_number}-${suggestion_end_line}` : `${line_number}`) : undefined
} }
}, null, 2), }, null, 2),
}, },
@ -532,7 +621,6 @@ export class PullRequestHandlers {
let totalCount = 0; let totalCount = 0;
if (this.apiClient.getIsServer()) { if (this.apiClient.getIsServer()) {
// Helper function to process nested comments recursively
const processNestedComments = (comment: any, anchor: any): FormattedComment => { const processNestedComments = (comment: any, anchor: any): FormattedComment => {
const formattedComment: FormattedComment = { const formattedComment: FormattedComment = {
id: comment.id, id: comment.id,
@ -545,11 +633,9 @@ export class PullRequestHandlers {
state: comment.state state: comment.state
}; };
// Process nested replies
if (comment.comments && comment.comments.length > 0) { if (comment.comments && comment.comments.length > 0) {
formattedComment.replies = comment.comments formattedComment.replies = comment.comments
.filter((reply: any) => { .filter((reply: any) => {
// Apply same filters to replies
if (reply.state === 'RESOLVED') return false; if (reply.state === 'RESOLVED') return false;
if (anchor && anchor.orphaned === true) return false; if (anchor && anchor.orphaned === true) return false;
return true; return true;
@ -560,7 +646,6 @@ export class PullRequestHandlers {
return formattedComment; return formattedComment;
}; };
// Helper to count all comments including nested ones
const countAllComments = (comment: any): number => { const countAllComments = (comment: any): number => {
let count = 1; let count = 1;
if (comment.comments && comment.comments.length > 0) { if (comment.comments && comment.comments.length > 0) {
@ -569,16 +654,13 @@ export class PullRequestHandlers {
return count; return count;
}; };
// Helper to count active comments including nested ones
const countActiveComments = (comment: any, anchor: any): number => { const countActiveComments = (comment: any, anchor: any): number => {
let count = 0; let count = 0;
// Check if this comment is active
if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) { if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) {
count = 1; count = 1;
} }
// Count active nested comments
if (comment.comments && comment.comments.length > 0) { if (comment.comments && comment.comments.length > 0) {
count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0); count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0);
} }
@ -586,7 +668,6 @@ export class PullRequestHandlers {
return count; return count;
}; };
// Bitbucket Server API - fetch from activities
const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`; const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`;
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
params: { limit: 1000 } params: { limit: 1000 }
@ -594,41 +675,32 @@ export class PullRequestHandlers {
const activities = response.values || []; const activities = response.values || [];
// Filter for comment activities
const commentActivities = activities.filter((a: any) => const commentActivities = activities.filter((a: any) =>
a.action === 'COMMENTED' && a.comment a.action === 'COMMENTED' && a.comment
); );
// Count all comments including nested ones
totalCount = commentActivities.reduce((sum: number, activity: any) => { totalCount = commentActivities.reduce((sum: number, activity: any) => {
return sum + countAllComments(activity.comment); return sum + countAllComments(activity.comment);
}, 0); }, 0);
// Count active comments including nested ones
activeCount = commentActivities.reduce((sum: number, activity: any) => { activeCount = commentActivities.reduce((sum: number, activity: any) => {
return sum + countActiveComments(activity.comment, activity.commentAnchor); return sum + countActiveComments(activity.comment, activity.commentAnchor);
}, 0); }, 0);
// Process top-level comments and their nested replies
const processedComments = commentActivities const processedComments = commentActivities
.filter((a: any) => { .filter((a: any) => {
const c = a.comment; const c = a.comment;
const anchor = a.commentAnchor; const anchor = a.commentAnchor;
// Skip resolved comments
if (c.state === 'RESOLVED') return false; if (c.state === 'RESOLVED') return false;
// Skip orphaned inline comments
if (anchor && anchor.orphaned === true) return false; if (anchor && anchor.orphaned === true) return false;
return true; return true;
}) })
.map((a: any) => processNestedComments(a.comment, a.commentAnchor)); .map((a: any) => processNestedComments(a.comment, a.commentAnchor));
// Limit to 20 top-level comments
comments = processedComments.slice(0, 20); comments = processedComments.slice(0, 20);
} else { } else {
// Bitbucket Cloud API
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`; const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`;
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
params: { pagelen: 100 } params: { pagelen: 100 }
@ -637,7 +709,6 @@ export class PullRequestHandlers {
const allComments = response.values || []; const allComments = response.values || [];
totalCount = allComments.length; totalCount = allComments.length;
// Filter for active comments (not deleted or resolved) and limit to 20
const activeComments = allComments const activeComments = allComments
.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved) .filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved)
.slice(0, 20); .slice(0, 20);
@ -673,7 +744,6 @@ export class PullRequestHandlers {
let totalLinesRemoved = 0; let totalLinesRemoved = 0;
if (this.apiClient.getIsServer()) { if (this.apiClient.getIsServer()) {
// Bitbucket Server API - use changes endpoint
const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`; const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`;
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
params: { limit: 1000 } params: { limit: 1000 }
@ -694,7 +764,6 @@ export class PullRequestHandlers {
}; };
}); });
} else { } else {
// Bitbucket Cloud API - use diffstat endpoint (has line statistics)
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`; const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`;
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
params: { pagelen: 100 } params: { pagelen: 100 }
@ -729,4 +798,299 @@ export class PullRequestHandlers {
}; };
} }
} }
private async resolveLineFromCode(
workspace: string,
repository: string,
pullRequestId: number,
filePath: string,
codeSnippet: string,
searchContext?: { before?: string[]; after?: string[] },
matchStrategy: 'strict' | 'best' = 'strict'
): Promise<{
line_number: number;
line_type: 'ADDED' | 'REMOVED' | 'CONTEXT';
sequential_position?: number;
hunk_info?: any;
diff_context?: string;
diff_content_preview?: string;
calculation_details?: string;
}> {
try {
const diffContent = await this.getFilteredPullRequestDiff(workspace, repository, pullRequestId, filePath);
const parser = new DiffParser();
const sections = parser.parseDiffIntoSections(diffContent);
let fileSection = sections[0];
if (!this.apiClient.getIsServer()) {
fileSection = sections.find(s => s.filePath === filePath) || sections[0];
}
if (!fileSection) {
throw new McpError(
ErrorCode.InvalidParams,
`File ${filePath} not found in pull request diff`
);
}
const matches = this.findCodeMatches(
fileSection.content,
codeSnippet,
searchContext
);
if (matches.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
`Code snippet not found in ${filePath}`
);
}
if (matches.length === 1) {
return {
line_number: matches[0].line_number,
line_type: matches[0].line_type,
sequential_position: matches[0].sequential_position,
hunk_info: matches[0].hunk_info,
diff_context: matches[0].preview,
diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'),
calculation_details: `Direct line number from diff: ${matches[0].line_number}`
};
}
if (matchStrategy === 'best') {
const best = this.selectBestMatch(matches);
return {
line_number: best.line_number,
line_type: best.line_type,
sequential_position: best.sequential_position,
hunk_info: best.hunk_info,
diff_context: best.preview,
diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'),
calculation_details: `Best match selected from ${matches.length} matches, line: ${best.line_number}`
};
}
const error: MultipleMatchesError = {
code: 'MULTIPLE_MATCHES_FOUND',
message: `Code snippet '${codeSnippet.substring(0, 50)}...' found in ${matches.length} locations`,
occurrences: matches.map(m => ({
line_number: m.line_number,
file_path: filePath,
preview: m.preview,
confidence: m.confidence,
line_type: m.line_type
})),
suggestion: 'To resolve, either:\n1. Add more context to uniquely identify the location\n2. Use match_strategy: \'best\' to auto-select highest confidence match\n3. Use line_number directly'
};
throw new McpError(
ErrorCode.InvalidParams,
JSON.stringify({ error })
);
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Failed to resolve line from code: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private findCodeMatches(
diffContent: string,
codeSnippet: string,
searchContext?: { before?: string[]; after?: string[] }
): CodeMatch[] {
const lines = diffContent.split('\n');
const matches: CodeMatch[] = [];
let currentDestLine = 0; // Destination file line number
let currentSrcLine = 0; // Source file line number
let inHunk = false;
let sequentialAddedCount = 0; // Track sequential ADDED lines
let currentHunkIndex = -1;
let currentHunkDestStart = 0;
let currentHunkSrcStart = 0;
let destPositionInHunk = 0; // Track position in destination file relative to hunk start
let srcPositionInHunk = 0; // Track position in source file relative to hunk start
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
if (match) {
currentHunkSrcStart = parseInt(match[1]);
currentHunkDestStart = parseInt(match[2]);
currentSrcLine = currentHunkSrcStart;
currentDestLine = currentHunkDestStart;
inHunk = true;
currentHunkIndex++;
destPositionInHunk = 0;
srcPositionInHunk = 0;
continue;
}
}
if (!inHunk) continue;
if (line === '') {
inHunk = false;
continue;
}
let lineType: 'ADDED' | 'REMOVED' | 'CONTEXT';
let lineContent = '';
let lineNumber = 0;
if (line.startsWith('+')) {
lineType = 'ADDED';
lineContent = line.substring(1);
lineNumber = currentHunkDestStart + destPositionInHunk;
destPositionInHunk++;
sequentialAddedCount++;
} else if (line.startsWith('-')) {
lineType = 'REMOVED';
lineContent = line.substring(1);
lineNumber = currentHunkSrcStart + srcPositionInHunk;
srcPositionInHunk++;
} else if (line.startsWith(' ')) {
lineType = 'CONTEXT';
lineContent = line.substring(1);
lineNumber = currentHunkDestStart + destPositionInHunk;
destPositionInHunk++;
srcPositionInHunk++;
} else {
inHunk = false;
continue;
}
if (lineContent.trim() === codeSnippet.trim()) {
const confidence = this.calculateConfidence(
lines,
i,
searchContext,
lineType
);
matches.push({
line_number: lineNumber,
line_type: lineType,
exact_content: codeSnippet,
preview: this.getPreview(lines, i),
confidence,
context: this.extractContext(lines, i),
sequential_position: lineType === 'ADDED' ? sequentialAddedCount : undefined,
hunk_info: {
hunk_index: currentHunkIndex,
destination_start: currentHunkDestStart,
line_in_hunk: destPositionInHunk
}
});
}
if (lineType === 'ADDED') {
currentDestLine++;
} else if (lineType === 'REMOVED') {
currentSrcLine++;
} else if (lineType === 'CONTEXT') {
currentSrcLine++;
currentDestLine++;
}
}
return matches;
}
private calculateConfidence(
lines: string[],
index: number,
searchContext?: { before?: string[]; after?: string[] },
lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT'
): number {
let confidence = 0.5; // Base confidence
if (!searchContext) {
return confidence;
}
if (searchContext.before) {
let matchedBefore = 0;
for (let j = 0; j < searchContext.before.length; j++) {
const contextLine = searchContext.before[searchContext.before.length - 1 - j];
const checkIndex = index - j - 1;
if (checkIndex >= 0) {
const checkLine = lines[checkIndex].substring(1);
if (checkLine.trim() === contextLine.trim()) {
matchedBefore++;
}
}
}
confidence += (matchedBefore / searchContext.before.length) * 0.3;
}
if (searchContext.after) {
let matchedAfter = 0;
for (let j = 0; j < searchContext.after.length; j++) {
const contextLine = searchContext.after[j];
const checkIndex = index + j + 1;
if (checkIndex < lines.length) {
const checkLine = lines[checkIndex].substring(1);
if (checkLine.trim() === contextLine.trim()) {
matchedAfter++;
}
}
}
confidence += (matchedAfter / searchContext.after.length) * 0.3;
}
if (lineType === 'ADDED') {
confidence += 0.1;
}
return Math.min(confidence, 1.0);
}
private getPreview(lines: string[], index: number): string {
const start = Math.max(0, index - 1);
const end = Math.min(lines.length, index + 2);
const previewLines = [];
for (let i = start; i < end; i++) {
const prefix = i === index ? '> ' : ' ';
previewLines.push(prefix + lines[i]);
}
return previewLines.join('\n');
}
private extractContext(lines: string[], index: number): { lines_before: string[]; lines_after: string[] } {
const linesBefore: string[] = [];
const linesAfter: string[] = [];
for (let i = Math.max(0, index - 2); i < index; i++) {
if (lines[i].match(/^[+\- ]/)) {
linesBefore.push(lines[i].substring(1));
}
}
for (let i = index + 1; i < Math.min(lines.length, index + 3); i++) {
if (lines[i].match(/^[+\- ]/)) {
linesAfter.push(lines[i].substring(1));
}
}
return {
lines_before: linesBefore,
lines_after: linesAfter
};
}
private selectBestMatch(matches: CodeMatch[]): CodeMatch {
return matches.sort((a, b) => b.confidence - a.confidence)[0];
}
} }

View file

@ -40,7 +40,7 @@ class BitbucketMCPServer {
this.server = new Server( this.server = new Server(
{ {
name: 'bitbucket-mcp-server', name: 'bitbucket-mcp-server',
version: '0.7.0', version: '0.9.0',
}, },
{ {
capabilities: { capabilities: {

View file

@ -140,7 +140,7 @@ export const toolDefinitions = [
}, },
{ {
name: 'add_comment', name: 'add_comment',
description: 'Add a comment to a pull request (general or inline on specific code)', description: 'Add a comment to a pull request. Supports: 1) General PR comments, 2) Replies to existing comments, 3) Inline comments on specific code lines (using line_number OR code_snippet), 4) Code suggestions for single or multi-line replacements. For inline comments, you can either provide exact line_number or use code_snippet to auto-detect the line.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -158,25 +158,58 @@ export const toolDefinitions = [
}, },
comment_text: { comment_text: {
type: 'string', type: 'string',
description: 'Comment text', description: 'The main comment text. For suggestions, this is the explanation before the code suggestion.',
}, },
parent_comment_id: { parent_comment_id: {
type: 'number', type: 'number',
description: 'Parent comment ID for replies (optional)', description: 'ID of comment to reply to. Use this to create threaded conversations (optional)',
}, },
file_path: { file_path: {
type: 'string', type: 'string',
description: 'File path for inline comment (optional, e.g., "src/main.js")', description: 'File path for inline comment. Required for inline comments. Example: "src/components/Button.js" (optional)',
}, },
line_number: { line_number: {
type: 'number', type: 'number',
description: 'Line number for inline comment (optional, required with file_path)', description: 'Exact line number in the file. Use this OR code_snippet, not both. Required with file_path unless using code_snippet (optional)',
}, },
line_type: { line_type: {
type: 'string', type: 'string',
description: 'Type of line for inline comment: ADDED, REMOVED, or CONTEXT (optional, default: CONTEXT)', description: 'Type of line: ADDED (green/new lines), REMOVED (red/deleted lines), or CONTEXT (unchanged lines). Default: CONTEXT',
enum: ['ADDED', 'REMOVED', 'CONTEXT'], enum: ['ADDED', 'REMOVED', 'CONTEXT'],
}, },
suggestion: {
type: 'string',
description: 'Replacement code for a suggestion. Creates a suggestion block that can be applied in Bitbucket UI. Requires file_path and line_number. For multi-line, include newlines in the string (optional)',
},
suggestion_end_line: {
type: 'number',
description: 'For multi-line suggestions: the last line number to replace. If not provided, only replaces the single line at line_number (optional)',
},
code_snippet: {
type: 'string',
description: 'Exact code text from the diff to find and comment on. Use this instead of line_number for auto-detection. Must match exactly including whitespace (optional)',
},
search_context: {
type: 'object',
properties: {
before: {
type: 'array',
items: { type: 'string' },
description: 'Array of code lines that appear BEFORE the target line. Helps disambiguate when code_snippet appears multiple times',
},
after: {
type: 'array',
items: { type: 'string' },
description: 'Array of code lines that appear AFTER the target line. Helps disambiguate when code_snippet appears multiple times',
},
},
description: 'Additional context lines to help locate the exact position when using code_snippet. Useful when the same code appears multiple times (optional)',
},
match_strategy: {
type: 'string',
enum: ['strict', 'best'],
description: 'How to handle multiple matches when using code_snippet. "strict": fail with detailed error showing all matches. "best": automatically pick the highest confidence match. Default: "strict"',
},
}, },
required: ['workspace', 'repository', 'pull_request_id', 'comment_text'], required: ['workspace', 'repository', 'pull_request_id', 'comment_text'],
}, },

View file

@ -352,3 +352,35 @@ export interface FormattedFileChange {
status: 'added' | 'modified' | 'removed' | 'renamed'; status: 'added' | 'modified' | 'removed' | 'renamed';
old_path?: string; old_path?: string;
} }
// Types for code snippet matching
export interface CodeMatch {
line_number: number;
line_type: 'ADDED' | 'REMOVED' | 'CONTEXT';
exact_content: string;
preview: string;
confidence: number;
context: {
lines_before: string[];
lines_after: string[];
};
sequential_position?: number; // Position within diff (for ADDED lines)
hunk_info?: {
hunk_index: number;
destination_start: number;
line_in_hunk: number;
};
}
export interface MultipleMatchesError {
code: 'MULTIPLE_MATCHES_FOUND';
message: string;
occurrences: Array<{
line_number: number;
file_path: string;
preview: string;
confidence: number;
line_type: 'ADDED' | 'REMOVED' | 'CONTEXT';
}>;
suggestion: string;
}

View file

@ -82,6 +82,14 @@ export const isAddCommentArgs = (
file_path?: string; file_path?: string;
line_number?: number; line_number?: number;
line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT'; line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT';
suggestion?: string;
suggestion_end_line?: number;
code_snippet?: string;
search_context?: {
before?: string[];
after?: string[];
};
match_strategy?: 'strict' | 'best';
} => } =>
typeof args === 'object' && typeof args === 'object' &&
args !== null && args !== null &&
@ -92,7 +100,16 @@ export const isAddCommentArgs = (
(args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') && (args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') &&
(args.file_path === undefined || typeof args.file_path === 'string') && (args.file_path === undefined || typeof args.file_path === 'string') &&
(args.line_number === undefined || typeof args.line_number === 'number') && (args.line_number === undefined || typeof args.line_number === 'number') &&
(args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)); (args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)) &&
(args.suggestion === undefined || typeof args.suggestion === 'string') &&
(args.suggestion_end_line === undefined || typeof args.suggestion_end_line === 'number') &&
(args.code_snippet === undefined || typeof args.code_snippet === 'string') &&
(args.search_context === undefined || (
typeof args.search_context === 'object' &&
(args.search_context.before === undefined || Array.isArray(args.search_context.before)) &&
(args.search_context.after === undefined || Array.isArray(args.search_context.after))
)) &&
(args.match_strategy === undefined || ['strict', 'best'].includes(args.match_strategy));
export const isMergePullRequestArgs = ( export const isMergePullRequestArgs = (
args: any args: any

View file

@ -0,0 +1,22 @@
/**
* Formats a comment with a code suggestion in markdown format
* that Bitbucket can render as an applicable suggestion
*/
export function formatSuggestionComment(
commentText: string,
suggestion: string,
startLine?: number,
endLine?: number
): string {
// Add line range info if it's a multi-line suggestion
const lineInfo = startLine && endLine && endLine > startLine
? ` (lines ${startLine}-${endLine})`
: '';
// Format with GitHub-style suggestion markdown
return `${commentText}${lineInfo}
\`\`\`suggestion
${suggestion}
\`\`\``;
}