@stubbedev/atlassian-mcp 0.1.16 → 0.1.18

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
@@ -273,10 +273,23 @@ This package is published to npm as `@stubbedev/atlassian-mcp`.
273
273
 
274
274
  Use semantic versioning for releases. Breaking tool-surface changes should bump the minor version while `<1.0.0` (for example `0.0.x` -> `0.1.0`).
275
275
 
276
- Automatic publish is configured in `.github/workflows/publish.yml`:
276
+ Automatic publish is configured in `.github/workflows/publish.yml` and runs when a new version tag is pushed.
277
277
 
278
- - Push a tag like `v1.0.1` to publish from CI
279
- - Or run the workflow manually via **Actions → Publish Package**
278
+ Release flow:
279
+
280
+ ```bash
281
+ # choose one: patch | minor | major
282
+ increment=patch
283
+
284
+ # bumps package.json + package-lock.json,
285
+ # creates a version commit, and creates a git tag (for example v0.1.17)
286
+ npm version "$increment"
287
+
288
+ # push commit and tag to GitHub
289
+ git push origin HEAD --follow-tags
290
+ ```
291
+
292
+ GitHub Actions will publish the npm release from that pushed tag.
280
293
 
281
294
  - The workflow is configured for npm Trusted Publisher (OIDC), so no `NPM_TOKEN` secret is required
282
295
 
@@ -284,13 +297,6 @@ Required npm setup (one-time):
284
297
 
285
298
  - In npm package settings, add this GitHub repo/workflow as a Trusted Publisher
286
299
 
287
- Manual publish from local machine:
288
-
289
- ```bash
290
- npm run build
291
- npm publish --access public
292
- ```
293
-
294
300
  ---
295
301
 
296
302
  ## Creating Personal Access Tokens
package/dist/bitbucket.js CHANGED
@@ -181,6 +181,7 @@ function validateCommentText(textValue) {
181
181
  export class BitbucketClient {
182
182
  baseUrl;
183
183
  headers;
184
+ currentUserCache;
184
185
  constructor(baseUrl, token) {
185
186
  this.baseUrl = baseUrl.replace(/\/$/, '');
186
187
  this.headers = {
@@ -237,6 +238,38 @@ export class BitbucketClient {
237
238
  }
238
239
  return res.text();
239
240
  }
241
+ normalizeIdentity(value) {
242
+ return (value ?? '').trim().toLowerCase();
243
+ }
244
+ async getCurrentUser() {
245
+ if (this.currentUserCache)
246
+ return this.currentUserCache;
247
+ const me = await this.request('GET', '/users/~self');
248
+ if (!me) {
249
+ throw new Error('Could not determine current Bitbucket user identity.');
250
+ }
251
+ this.currentUserCache = me;
252
+ return me;
253
+ }
254
+ async assertOwnComment(comment) {
255
+ const me = await this.getCurrentUser();
256
+ const commentAuthorName = this.normalizeIdentity(comment.author?.name);
257
+ const commentAuthorDisplayName = this.normalizeIdentity(comment.author?.displayName);
258
+ const meName = this.normalizeIdentity(me.name);
259
+ const meSlug = this.normalizeIdentity(me.slug);
260
+ const meDisplayName = this.normalizeIdentity(me.displayName);
261
+ const hasStrongCommentIdentity = commentAuthorName.length > 0;
262
+ const hasStrongUserIdentity = meName.length > 0 || meSlug.length > 0;
263
+ const matchesByName = commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meSlug);
264
+ const matchesByDisplayNameFallback = !hasStrongCommentIdentity
265
+ && !hasStrongUserIdentity
266
+ && commentAuthorDisplayName.length > 0
267
+ && commentAuthorDisplayName === meDisplayName;
268
+ if (matchesByName || matchesByDisplayNameFallback) {
269
+ return;
270
+ }
271
+ 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'}.`);
272
+ }
240
273
  // Used internally by context tools — finds the open PR for a given source branch
241
274
  async findOpenPrForBranch(projectKey, repoSlug, branch) {
242
275
  const targetBranch = branchDisplayId(branch);
@@ -758,6 +791,7 @@ export class BitbucketClient {
758
791
  const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
759
792
  if (!current)
760
793
  throw new Error(`Comment #${args.commentId} not found.`);
794
+ await this.assertOwnComment(current);
761
795
  const targetSeverity = args.severity ?? current.severity ?? 'NORMAL';
762
796
  if (args.state && targetSeverity !== 'BLOCKER') {
763
797
  throw new Error('state is only supported for BLOCKER comments (tasks). Use threadResolved for normal comment threads.');
@@ -807,7 +841,11 @@ export class BitbucketClient {
807
841
  const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
808
842
  if (!current)
809
843
  throw new Error(`Comment #${args.commentId} not found.`);
810
- const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}?version=${current.version}`;
844
+ await this.assertOwnComment(current);
845
+ const commentPath = current.severity === 'BLOCKER'
846
+ ? `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
847
+ : `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`;
848
+ const path = `${commentPath}?version=${current.version}`;
811
849
  await this.request('DELETE', path);
812
850
  return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
813
851
  }
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ function normalizeJiraMutateArgs(args) {
39
39
  }
40
40
  return out;
41
41
  }
42
+ const JIRA_WIKI_MARKUP_HINT = 'Use Jira wiki markup (Atlassian renderer syntax), not GitHub/CommonMark markdown.';
42
43
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
43
44
  tools: [
44
45
  // ── Context ───────────────────────────────────────────────────────────
@@ -157,7 +158,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
157
158
  },
158
159
  {
159
160
  name: 'jira_create_issue',
160
- description: 'Use when you want to create a new Jira ticket (bug, story, task, etc.). If projectKey/project is omitted, the server auto-picks from branch context or asks you to choose.',
161
+ description: `Use when you want to create a new Jira ticket (bug, story, task, etc.). If projectKey/project is omitted, the server auto-picks from branch context or asks you to choose. ${JIRA_WIKI_MARKUP_HINT}`,
161
162
  inputSchema: {
162
163
  type: 'object',
163
164
  properties: {
@@ -165,7 +166,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
165
166
  project: { type: 'string', description: 'Alias for projectKey' },
166
167
  issueType: { type: 'string', description: 'Issue type name, for example "Bug", "Story", or "Task"' },
167
168
  summary: { type: 'string', description: 'Issue title' },
168
- description: { type: 'string', description: 'Issue description (optional)' },
169
+ description: { type: 'string', description: `Issue description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
169
170
  assignee: { type: 'string', description: 'Username to assign to (optional)' },
170
171
  priority: { type: 'string', description: 'Priority name, e.g. "High" (optional)' },
171
172
  sprintId: { type: 'number', description: 'Sprint ID to immediately add the new issue into (optional)' },
@@ -175,13 +176,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
175
176
  },
176
177
  {
177
178
  name: 'jira_update_issue',
178
- description: 'Use when you want to edit an existing Jira ticket: title, description, assignee, or priority.',
179
+ description: `Use when you want to edit an existing Jira ticket: title, description, assignee, or priority. ${JIRA_WIKI_MARKUP_HINT}`,
179
180
  inputSchema: {
180
181
  type: 'object',
181
182
  properties: {
182
183
  issueKey: { type: 'string', description: 'Jira issue key' },
183
184
  summary: { type: 'string', description: 'New summary (optional)' },
184
- description: { type: 'string', description: 'New description (optional)' },
185
+ description: { type: 'string', description: `New description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
185
186
  assignee: { type: 'string', description: 'New assignee username, or empty string to unassign (optional)' },
186
187
  priority: { type: 'string', description: 'New priority name (optional)' },
187
188
  sprintId: { type: 'number', description: 'Sprint ID to add this issue into (optional)' },
@@ -204,7 +205,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
204
205
  },
205
206
  {
206
207
  name: 'jira_mutate_issue',
207
- description: 'Use when you want to bundle Jira mutations in one call: create or target an issue, then optional update, sprint assignment, transition, and comment.',
208
+ description: `Use when you want to bundle Jira mutations in one call: create or target an issue, then optional update, sprint assignment, transition, and comment. ${JIRA_WIKI_MARKUP_HINT}`,
208
209
  inputSchema: {
209
210
  type: 'object',
210
211
  properties: {
@@ -216,7 +217,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
216
217
  project: { type: 'string', description: 'Alias for projectKey' },
217
218
  issueType: { type: 'string', description: 'Issue type name, e.g. Bug, Story, Task' },
218
219
  summary: { type: 'string', description: 'Issue title' },
219
- description: { type: 'string', description: 'Issue description (optional)' },
220
+ description: { type: 'string', description: `Issue description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
220
221
  assignee: { type: 'string', description: 'Username to assign to (optional)' },
221
222
  priority: { type: 'string', description: 'Priority name (optional)' },
222
223
  },
@@ -226,7 +227,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
226
227
  type: 'object',
227
228
  properties: {
228
229
  summary: { type: 'string', description: 'New summary (optional)' },
229
- description: { type: 'string', description: 'New description (optional)' },
230
+ description: { type: 'string', description: `New description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
230
231
  assignee: { type: 'string', description: 'New assignee username, or empty string to unassign (optional)' },
231
232
  priority: { type: 'string', description: 'New priority name (optional)' },
232
233
  },
@@ -234,7 +235,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
234
235
  sprintId: { type: 'number', description: 'Sprint ID to add the issue into (optional)' },
235
236
  transitionId: { type: 'string', description: 'Transition ID (optional if transitionName is provided)' },
236
237
  transitionName: { type: 'string', description: 'Transition name, e.g. In Progress (optional if transitionId is provided)' },
237
- comment: { type: 'string', description: 'Comment to add after other mutations (optional, no emoji)' },
238
+ comment: { type: 'string', description: `Comment to add after other mutations (optional, no emoji). ${JIRA_WIKI_MARKUP_HINT}` },
238
239
  },
239
240
  },
240
241
  },
@@ -259,7 +260,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
259
260
  },
260
261
  {
261
262
  name: 'jira_get_comments',
262
- description: 'Use when you want the discussion thread on a Jira ticket, with pagination for long threads.',
263
+ description: 'Use when you want the discussion thread on a Jira ticket, with pagination for long threads. Includes comment IDs for follow-up edits.',
263
264
  inputSchema: {
264
265
  type: 'object',
265
266
  properties: {
@@ -272,16 +273,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
272
273
  },
273
274
  {
274
275
  name: 'jira_add_comment',
275
- description: 'Use when you want to leave a comment on a Jira ticket. Keep comments concise, plain text, and free of filler. Never include emojis.',
276
+ description: `Use when you want to leave a comment on a Jira ticket. Keep comments concise and free of filler. Never include emojis. ${JIRA_WIKI_MARKUP_HINT}`,
276
277
  inputSchema: {
277
278
  type: 'object',
278
279
  properties: {
279
280
  issueKey: { type: 'string', description: 'Jira issue key' },
280
- body: { type: 'string', description: 'Concise comment text only. No filler. Do not include emojis.' },
281
+ body: { type: 'string', description: `Concise comment text. No filler. Do not include emojis. ${JIRA_WIKI_MARKUP_HINT}` },
281
282
  },
282
283
  required: ['issueKey', 'body'],
283
284
  },
284
285
  },
286
+ {
287
+ name: 'jira_edit_comment',
288
+ description: `Use when you want to edit one of your own comments on a Jira ticket. Editing comments from other users is rejected. Never include emojis. ${JIRA_WIKI_MARKUP_HINT}`,
289
+ inputSchema: {
290
+ type: 'object',
291
+ properties: {
292
+ issueKey: { type: 'string', description: 'Jira issue key' },
293
+ commentId: { type: 'string', description: 'Jira comment ID (from jira_get_comments or jira_issue_overview output)' },
294
+ body: { type: 'string', description: `Updated concise comment text. No filler. Do not include emojis. ${JIRA_WIKI_MARKUP_HINT}` },
295
+ },
296
+ required: ['issueKey', 'commentId', 'body'],
297
+ },
298
+ },
299
+ {
300
+ name: 'jira_delete_comment',
301
+ description: 'Use when you want to delete one of your own comments on a Jira ticket by comment ID. Deleting comments from other users is rejected.',
302
+ inputSchema: {
303
+ type: 'object',
304
+ properties: {
305
+ issueKey: { type: 'string', description: 'Jira issue key' },
306
+ commentId: { type: 'string', description: 'Jira comment ID to delete (from jira_get_comments or jira_issue_overview output)' },
307
+ },
308
+ required: ['issueKey', 'commentId'],
309
+ },
310
+ },
285
311
  {
286
312
  name: 'jira_transition_issue',
287
313
  description: 'Use when you want to move a Jira ticket to another status. Provide a transition name (for example "In Progress") or a transition ID.',
@@ -604,7 +630,7 @@ Keep comments concise, plain text, and free of filler. Never include emojis. You
604
630
  },
605
631
  {
606
632
  name: 'bitbucket_update_pr_comment',
607
- description: 'Use when you want to edit PR comments, resolve/reopen normal discussion threads, or manage task-style BLOCKER comments. Hint: for normal comments, resolve/reopen means threadResolved; for BLOCKER tasks, resolve/reopen uses state. Keep comments concise, plain text, and free of filler. Never include emojis. You can pass projectKey/repoSlug or project/repo.',
633
+ description: 'Use when you want to edit your own PR comments, resolve/reopen normal discussion threads, or manage task-style BLOCKER comments. Editing comments from other users is rejected. Hint: for normal comments, resolve/reopen means threadResolved; for BLOCKER tasks, resolve/reopen uses state. Keep comments concise, plain text, and free of filler. Never include emojis. You can pass projectKey/repoSlug or project/repo.',
608
634
  inputSchema: {
609
635
  type: 'object',
610
636
  properties: {
@@ -624,7 +650,7 @@ Keep comments concise, plain text, and free of filler. Never include emojis. You
624
650
  },
625
651
  {
626
652
  name: 'bitbucket_delete_pr_comment',
627
- description: 'Use when you want to delete a PR comment by comment ID. You can pass projectKey/repoSlug or project/repo.',
653
+ description: 'Use when you want to delete one of your own PR comments by comment ID. Deleting comments from other users is rejected. You can pass projectKey/repoSlug or project/repo.',
628
654
  inputSchema: {
629
655
  type: 'object',
630
656
  properties: {
@@ -746,6 +772,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
746
772
  return await jira.getComments(args);
747
773
  case 'jira_add_comment':
748
774
  return await jira.addComment(args);
775
+ case 'jira_edit_comment':
776
+ return await jira.editComment(args);
777
+ case 'jira_delete_comment':
778
+ return await jira.deleteComment(args);
749
779
  case 'jira_transition_issue':
750
780
  return await jira.transitionIssue(args);
751
781
  // Bitbucket
package/dist/jira.js CHANGED
@@ -83,13 +83,14 @@ function validateCommentBody(body) {
83
83
  throw new Error('Jira comment body must not be empty.');
84
84
  }
85
85
  if (EMOJI_RE.test(trimmed)) {
86
- throw new Error('Jira comments must not include emoji. Use concise plain text only.');
86
+ throw new Error('Jira comments must not include emoji. Use concise Jira wiki markup or plain text only.');
87
87
  }
88
88
  return trimmed;
89
89
  }
90
90
  export class JiraClient {
91
91
  baseUrl;
92
92
  headers;
93
+ currentUserCache;
93
94
  constructor(baseUrl, token) {
94
95
  this.baseUrl = baseUrl.replace(/\/$/, '');
95
96
  this.headers = {
@@ -129,6 +130,40 @@ export class JiraClient {
129
130
  async requestAgile(method, path, body) {
130
131
  return this.requestWithBase('/rest/agile/1.0', method, path, body);
131
132
  }
133
+ normalizeIdentity(value) {
134
+ return (value ?? '').trim().toLowerCase();
135
+ }
136
+ async getCurrentUser() {
137
+ if (this.currentUserCache)
138
+ return this.currentUserCache;
139
+ const me = await this.request('GET', '/myself');
140
+ if (!me) {
141
+ throw new Error('Could not determine current Jira user identity.');
142
+ }
143
+ this.currentUserCache = me;
144
+ return me;
145
+ }
146
+ async assertOwnComment(comment) {
147
+ const me = await this.getCurrentUser();
148
+ const commentAuthorName = this.normalizeIdentity(comment.author.name);
149
+ const commentAuthorKey = this.normalizeIdentity(comment.author.key);
150
+ const commentAuthorDisplayName = this.normalizeIdentity(comment.author.displayName);
151
+ const meName = this.normalizeIdentity(me.name);
152
+ const meKey = this.normalizeIdentity(me.key);
153
+ const meDisplayName = this.normalizeIdentity(me.displayName);
154
+ const hasStrongCommentIdentity = commentAuthorName.length > 0 || commentAuthorKey.length > 0;
155
+ const hasStrongUserIdentity = meName.length > 0 || meKey.length > 0;
156
+ const matchesByNameOrKey = (commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meKey))
157
+ || (commentAuthorKey.length > 0 && (commentAuthorKey === meName || commentAuthorKey === meKey));
158
+ const matchesByDisplayNameFallback = !hasStrongCommentIdentity
159
+ && !hasStrongUserIdentity
160
+ && commentAuthorDisplayName.length > 0
161
+ && commentAuthorDisplayName === meDisplayName;
162
+ if (matchesByNameOrKey || matchesByDisplayNameFallback) {
163
+ return;
164
+ }
165
+ throw new Error(`You can only edit your own Jira comments. Comment ${comment.id} is authored by ${comment.author.displayName}.`);
166
+ }
132
167
  async addIssuesToSprintInternal(sprintId, issueKeys) {
133
168
  await this.requestAgile('POST', `/sprint/${sprintId}/issue`, { issues: issueKeys });
134
169
  }
@@ -365,7 +400,7 @@ export class JiraClient {
365
400
  else {
366
401
  for (const c of items) {
367
402
  const date = c.created.slice(0, 10);
368
- lines.push(`--- ${c.author.displayName} (${date}) ---`, c.body, '');
403
+ lines.push(`--- #${c.id} ${c.author.displayName} (${date}) ---`, c.body, '');
369
404
  }
370
405
  }
371
406
  }
@@ -536,7 +571,7 @@ export class JiraClient {
536
571
  return text('No comments found.');
537
572
  const blocks = data.comments.map((c) => {
538
573
  const date = c.created.slice(0, 10);
539
- return `--- ${c.author.displayName} (${date}) ---\n${c.body}`;
574
+ return `--- #${c.id} ${c.author.displayName} (${date}) ---\n${c.body}`;
540
575
  });
541
576
  const page = pagination(data.total, startAt, data.comments.length);
542
577
  return text(`${data.total} comment(s) on ${issueKey}${page}:\n\n${blocks.join('\n\n')}`);
@@ -545,6 +580,32 @@ export class JiraClient {
545
580
  await this.request('POST', `/issue/${args.issueKey}/comment`, { body: validateCommentBody(args.body) });
546
581
  return text(`Comment added to ${args.issueKey}.`);
547
582
  }
583
+ async editComment(args) {
584
+ const commentId = String(args.commentId).trim();
585
+ if (!commentId) {
586
+ throw new Error('commentId is required.');
587
+ }
588
+ const path = `/issue/${args.issueKey}/comment/${commentId}`;
589
+ const current = await this.request('GET', path);
590
+ if (!current)
591
+ throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
592
+ await this.assertOwnComment(current);
593
+ await this.request('PUT', path, { body: validateCommentBody(args.body) });
594
+ return text(`Comment ${commentId} updated on ${args.issueKey}.`);
595
+ }
596
+ async deleteComment(args) {
597
+ const commentId = String(args.commentId).trim();
598
+ if (!commentId) {
599
+ throw new Error('commentId is required.');
600
+ }
601
+ const path = `/issue/${args.issueKey}/comment/${commentId}`;
602
+ const current = await this.request('GET', path);
603
+ if (!current)
604
+ throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
605
+ await this.assertOwnComment(current);
606
+ await this.request('DELETE', path);
607
+ return text(`Comment ${commentId} deleted from ${args.issueKey}.`);
608
+ }
548
609
  async transitionIssue(args) {
549
610
  const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
550
611
  await this.transitionIssueInternal(args.issueKey, transitionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",