@stubbedev/atlassian-mcp 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bitbucket.js +41 -66
- package/dist/index.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
|
|
|
39
39
|
| `bitbucket_search` | Discover resources: `pull_requests` (default), `repos`, or `branches` via `resource` param; `mine=true` for your inbox |
|
|
40
40
|
| `bitbucket_get_pr` | Full PR details: metadata, commits, comments, blockers, build status, and optional diff |
|
|
41
41
|
| `bitbucket_mutate` | Create/update a PR, or perform lifecycle actions: `approve`, `unapprove`, `merge`, `decline` |
|
|
42
|
-
| `bitbucket_comment` | Add, update, or delete a PR comment;
|
|
42
|
+
| `bitbucket_comment` | Add, update, or delete a PR comment; for code changes use `suggestion` so Bitbucket shows Apply suggestion (no trailing text after a suggestion block) |
|
|
43
43
|
| `bitbucket_get_file` | Raw file content from Bitbucket at a branch, tag, or commit |
|
|
44
44
|
| `bitbucket_pr_tasks` | Manage PR tasks (checklist items): `list`, `create`, `resolve`, `reopen`, `delete` |
|
|
45
45
|
|
package/dist/bitbucket.js
CHANGED
|
@@ -180,10 +180,22 @@ function validateCommentText(textValue) {
|
|
|
180
180
|
}
|
|
181
181
|
return trimmed;
|
|
182
182
|
}
|
|
183
|
+
function validateSuggestionPlacement(textValue) {
|
|
184
|
+
if (!textValue.includes('```suggestion'))
|
|
185
|
+
return;
|
|
186
|
+
const match = textValue.match(/```suggestion[^\n]*\n[\s\S]*?\n```/);
|
|
187
|
+
if (!match || match.index === undefined) {
|
|
188
|
+
throw new Error('Invalid suggestion block format. Use the suggestion field to post code suggestions.');
|
|
189
|
+
}
|
|
190
|
+
const trailingText = textValue.slice(match.index + match[0].length).trim();
|
|
191
|
+
if (trailingText.length > 0) {
|
|
192
|
+
throw new Error('When using ```suggestion```, do not add text after the closing code fence. Put any explanation before the suggestion block or use the suggestion field.');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
183
195
|
export class BitbucketClient {
|
|
184
196
|
baseUrl;
|
|
185
197
|
headers;
|
|
186
|
-
|
|
198
|
+
currentUsernameCache;
|
|
187
199
|
constructor(baseUrl, token) {
|
|
188
200
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
189
201
|
this.headers = {
|
|
@@ -192,6 +204,18 @@ export class BitbucketClient {
|
|
|
192
204
|
Accept: 'application/json',
|
|
193
205
|
};
|
|
194
206
|
}
|
|
207
|
+
/** Returns the slug/username of the authenticated user via the X-AUSERNAME response header. */
|
|
208
|
+
async getCurrentUsername() {
|
|
209
|
+
if (this.currentUsernameCache)
|
|
210
|
+
return this.currentUsernameCache;
|
|
211
|
+
const url = `${this.baseUrl}/rest/api/1.0/application-properties`;
|
|
212
|
+
const res = await fetch(url, { method: 'GET', headers: this.headers });
|
|
213
|
+
const username = res.headers.get('X-AUSERNAME');
|
|
214
|
+
if (!username)
|
|
215
|
+
throw new Error('Could not determine current Bitbucket user. Check token permissions.');
|
|
216
|
+
this.currentUsernameCache = username;
|
|
217
|
+
return username;
|
|
218
|
+
}
|
|
195
219
|
/** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
|
|
196
220
|
rp(projectKey, repoSlug) {
|
|
197
221
|
return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
|
|
@@ -278,38 +302,6 @@ export class BitbucketClient {
|
|
|
278
302
|
}
|
|
279
303
|
return res.status === 204 ? null : res.json();
|
|
280
304
|
}
|
|
281
|
-
normalizeIdentity(value) {
|
|
282
|
-
return (value ?? '').trim().toLowerCase();
|
|
283
|
-
}
|
|
284
|
-
async getCurrentUser() {
|
|
285
|
-
if (this.currentUserCache)
|
|
286
|
-
return this.currentUserCache;
|
|
287
|
-
const me = await this.request('GET', '/users/~self');
|
|
288
|
-
if (!me) {
|
|
289
|
-
throw new Error('Could not determine current Bitbucket user identity.');
|
|
290
|
-
}
|
|
291
|
-
this.currentUserCache = me;
|
|
292
|
-
return me;
|
|
293
|
-
}
|
|
294
|
-
async assertOwnComment(comment) {
|
|
295
|
-
const me = await this.getCurrentUser();
|
|
296
|
-
const commentAuthorName = this.normalizeIdentity(comment.author?.name);
|
|
297
|
-
const commentAuthorDisplayName = this.normalizeIdentity(comment.author?.displayName);
|
|
298
|
-
const meName = this.normalizeIdentity(me.name);
|
|
299
|
-
const meSlug = this.normalizeIdentity(me.slug);
|
|
300
|
-
const meDisplayName = this.normalizeIdentity(me.displayName);
|
|
301
|
-
const hasStrongCommentIdentity = commentAuthorName.length > 0;
|
|
302
|
-
const hasStrongUserIdentity = meName.length > 0 || meSlug.length > 0;
|
|
303
|
-
const matchesByName = commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meSlug);
|
|
304
|
-
const matchesByDisplayNameFallback = !hasStrongCommentIdentity
|
|
305
|
-
&& !hasStrongUserIdentity
|
|
306
|
-
&& commentAuthorDisplayName.length > 0
|
|
307
|
-
&& commentAuthorDisplayName === meDisplayName;
|
|
308
|
-
if (matchesByName || matchesByDisplayNameFallback) {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
throw new Error(`You can only edit your own Bitbucket comments. Comment #${comment.id} is authored by ${comment.author?.displayName ?? comment.author?.name ?? 'another user'}.`);
|
|
312
|
-
}
|
|
313
305
|
/** Returns true if the given remote URL belongs to this Bitbucket instance. */
|
|
314
306
|
isRemoteForThisInstance(remoteUrl) {
|
|
315
307
|
return this.remoteMatchesInstance(remoteUrl);
|
|
@@ -367,10 +359,7 @@ export class BitbucketClient {
|
|
|
367
359
|
}
|
|
368
360
|
async myPrs(args) {
|
|
369
361
|
const { limit = 25, start = 0, role } = args;
|
|
370
|
-
const
|
|
371
|
-
const userSlug = me.slug ?? me.name;
|
|
372
|
-
if (!userSlug)
|
|
373
|
-
throw new Error('Could not determine your Bitbucket user slug. Check token permissions.');
|
|
362
|
+
const userSlug = await this.getCurrentUsername();
|
|
374
363
|
const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
|
|
375
364
|
if (role)
|
|
376
365
|
qs.set('role', role.toUpperCase());
|
|
@@ -424,28 +413,12 @@ export class BitbucketClient {
|
|
|
424
413
|
throw new Error(`No open PR found for branch "${branchDisplayId(branch)}".`);
|
|
425
414
|
prId = found.id;
|
|
426
415
|
}
|
|
427
|
-
const
|
|
428
|
-
this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`),
|
|
429
|
-
this.getCurrentUser().catch(() => null),
|
|
430
|
-
]);
|
|
416
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`);
|
|
431
417
|
if (!pr)
|
|
432
418
|
return text('Pull request not found.');
|
|
433
419
|
const sections = [];
|
|
434
420
|
const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
|
|
435
421
|
const url = pr.links?.self?.[0]?.href;
|
|
436
|
-
// Prefer slug (canonical unique identifier), fall back to username (name field).
|
|
437
|
-
// Display name is deliberately excluded — it is not unique.
|
|
438
|
-
const meSlug = this.normalizeIdentity(me?.slug);
|
|
439
|
-
const meName = this.normalizeIdentity(me?.name);
|
|
440
|
-
const authorSlug = this.normalizeIdentity(pr.author.user.slug);
|
|
441
|
-
const authorName = this.normalizeIdentity(pr.author.user.name);
|
|
442
|
-
const isAuthor = me
|
|
443
|
-
? (meSlug && authorSlug ? meSlug === authorSlug : false) ||
|
|
444
|
-
(meName && authorName ? meName === authorName : false)
|
|
445
|
-
: false;
|
|
446
|
-
const viewingAs = me
|
|
447
|
-
? `Viewing as: ${me.displayName} (${isAuthor ? 'you are the author' : 'you are a reviewer'})`
|
|
448
|
-
: '';
|
|
449
422
|
const header = [
|
|
450
423
|
`PR #${pr.id}: ${pr.title}`,
|
|
451
424
|
`State: ${pr.state}`,
|
|
@@ -453,7 +426,6 @@ export class BitbucketClient {
|
|
|
453
426
|
`Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
|
|
454
427
|
`Reviewers: ${reviewers || 'None'}`,
|
|
455
428
|
url ? `URL: ${url}` : '',
|
|
456
|
-
viewingAs,
|
|
457
429
|
'',
|
|
458
430
|
'Description:',
|
|
459
431
|
pr.description ?? '(no description)',
|
|
@@ -850,10 +822,21 @@ export class BitbucketClient {
|
|
|
850
822
|
|| args.multilineStartLineType !== undefined)) {
|
|
851
823
|
throw new Error('Replies must target an existing comment thread only. Omit filePath/line and other anchor fields when replying.');
|
|
852
824
|
}
|
|
853
|
-
|
|
825
|
+
if (args.text === undefined && args.suggestion === undefined) {
|
|
826
|
+
throw new Error('Either text or suggestion is required when adding a comment.');
|
|
827
|
+
}
|
|
828
|
+
let commentText = args.text ?? '';
|
|
854
829
|
if (args.suggestion !== undefined) {
|
|
855
|
-
const
|
|
856
|
-
|
|
830
|
+
const suggestion = args.suggestion.trim();
|
|
831
|
+
if (!suggestion) {
|
|
832
|
+
throw new Error('suggestion must not be empty.');
|
|
833
|
+
}
|
|
834
|
+
const suggestionBlock = `\`\`\`suggestion\n${suggestion}\n\`\`\``;
|
|
835
|
+
const prefix = (args.text ?? '').trim();
|
|
836
|
+
commentText = prefix ? `${prefix}\n\n${suggestionBlock}` : suggestionBlock;
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
validateSuggestionPlacement(commentText);
|
|
857
840
|
}
|
|
858
841
|
const body = { text: validateCommentText(commentText) };
|
|
859
842
|
if (args.severity)
|
|
@@ -918,13 +901,6 @@ export class BitbucketClient {
|
|
|
918
901
|
if (!current)
|
|
919
902
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
920
903
|
const currentSeverity = current.severity ?? 'NORMAL';
|
|
921
|
-
const severityIsChanging = args.severity !== undefined && args.severity !== currentSeverity;
|
|
922
|
-
const isResolutionOnlyUpdate = (args.state !== undefined || args.threadResolved !== undefined)
|
|
923
|
-
&& args.text === undefined
|
|
924
|
-
&& !severityIsChanging;
|
|
925
|
-
if (!isResolutionOnlyUpdate) {
|
|
926
|
-
await this.assertOwnComment(current);
|
|
927
|
-
}
|
|
928
904
|
const targetSeverity = args.severity ?? currentSeverity;
|
|
929
905
|
if (args.state && targetSeverity !== 'BLOCKER') {
|
|
930
906
|
throw new Error('state is only supported for BLOCKER comments (tasks). Use threadResolved for normal comment threads.');
|
|
@@ -974,7 +950,6 @@ export class BitbucketClient {
|
|
|
974
950
|
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
975
951
|
if (!current)
|
|
976
952
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
977
|
-
await this.assertOwnComment(current);
|
|
978
953
|
const commentPath = current.severity === 'BLOCKER'
|
|
979
954
|
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
980
955
|
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
package/dist/index.js
CHANGED
|
@@ -348,7 +348,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
348
348
|
},
|
|
349
349
|
{
|
|
350
350
|
name: 'bitbucket_comment',
|
|
351
|
-
description: `Add, update, or delete a PR comment. action defaults to "add". For
|
|
351
|
+
description: `Add, update, or delete a PR comment. action defaults to "add". For code changes, ALWAYS use inline comments with suggestion when exact replacement code is available. Keep any explanatory text before the suggestion block only (never after), or Bitbucket may hide Apply suggestion. Replies MUST use commentId. Keep comments concise, no emojis. Only call proactively (without being asked) when you are a reviewer on the PR (i.e. "Viewing as" says "you are a reviewer") — never post unsolicited comments on PRs you authored.`,
|
|
352
352
|
inputSchema: {
|
|
353
353
|
type: 'object',
|
|
354
354
|
properties: {
|
|
@@ -359,7 +359,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
359
359
|
repo: { type: 'string', description: 'Alias for repoSlug' },
|
|
360
360
|
prId: { type: 'number', description: 'Pull request number' },
|
|
361
361
|
commentId: { type: 'number', description: 'Comment ID to reply to, update, or delete' },
|
|
362
|
-
text: { type: 'string', description: 'Comment text. No filler, no emojis.
|
|
362
|
+
text: { type: 'string', description: 'Comment text for add/update. No filler, no emojis. If suggestion is used, keep this optional and brief; it is placed before the suggestion block.' },
|
|
363
363
|
filePath: { type: 'string', description: 'File path for inline comment (must pair with line)' },
|
|
364
364
|
srcPath: { type: 'string', description: 'Source path if file was renamed (optional, defaults to filePath)' },
|
|
365
365
|
line: { type: 'number', description: 'Line number to anchor inline comment (must pair with filePath)' },
|
|
@@ -367,7 +367,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
367
367
|
fileType: { type: 'string', enum: ['TO', 'FROM'], description: 'Diff side: TO (new, default) or FROM (old)' },
|
|
368
368
|
multilineStartLine: { type: 'number', description: 'First line of multiline anchor (pair with line as last line)' },
|
|
369
369
|
multilineStartLineType: { type: 'string', enum: ['ADDED', 'REMOVED', 'CONTEXT'], description: 'Line type for multilineStartLine' },
|
|
370
|
-
suggestion: { type: 'string', description: 'Replacement code to suggest
|
|
370
|
+
suggestion: { type: 'string', description: 'Replacement code to suggest. Use whenever proposing a concrete code change. Posted as the final ```suggestion``` block so Apply suggestion appears. Requires filePath + line.' },
|
|
371
371
|
state: { type: 'string', enum: ['OPEN', 'RESOLVED'], description: 'Task state for BLOCKER comments (update only)' },
|
|
372
372
|
threadResolved: { type: 'boolean', description: 'Resolve/reopen normal comment thread (update only)' },
|
|
373
373
|
severity: { type: 'string', enum: ['NORMAL', 'BLOCKER'], description: 'Comment severity. BLOCKER = checklist task.' },
|