@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 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; supports inline anchors, multiline, suggestions, and BLOCKER severity |
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
- currentUserCache;
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 me = await this.getCurrentUser();
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 [pr, me] = await Promise.all([
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
- let commentText = args.text;
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 suggestionBlock = `\`\`\`suggestion\n${args.suggestion}\n\`\`\``;
856
- commentText = args.text ? `${args.text}\n\n${suggestionBlock}` : suggestionBlock;
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 review feedback, prefer inline comments with a code 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.`,
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. Required for add/update.' },
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 (strongly preferred for code changes). Requires filePath + line.' },
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.' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",