@stubbedev/atlassian-mcp 0.3.4 → 0.3.6

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
@@ -67,6 +67,8 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
67
67
  - "list releases for PAY" → `jira_search` with `resource=versions`, `project=PAY`
68
68
  - "release version 12345" → `jira_version` with `action=release`, `id=12345`
69
69
  - "set fix version 9.1.0 on FOO-123" → `jira_mutate` with `update.fixVersion=9.1.0`
70
+ - "create a task under epic FOO-100" → `jira_mutate` with `create.issueType=Task`, `create.parent=FOO-100` (auto-detects Epic and sets Epic Link)
71
+ - "move FOO-123 under epic FOO-100" → `jira_mutate` with `update.epicLink=FOO-100`
70
72
 
71
73
  ---
72
74
 
package/dist/bitbucket.js CHANGED
@@ -810,6 +810,15 @@ export class BitbucketClient {
810
810
  await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
811
811
  return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
812
812
  }
813
+ async needsWorkPr(args) {
814
+ const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
815
+ const userSlug = await this.getCurrentUsername();
816
+ const data = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/participants/${encodeURIComponent(userSlug)}`, { user: { name: userSlug }, approved: false, status: 'NEEDS_WORK' });
817
+ const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
818
+ if (!data)
819
+ return text(`Marked PR #${args.prId} as Needs work.\n${url}`);
820
+ return text(`Marked PR #${args.prId} as Needs work as ${data.user.displayName}.\n${url}`);
821
+ }
813
822
  async declinePr(args) {
814
823
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
815
824
  const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
package/dist/index.js CHANGED
@@ -261,7 +261,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
261
261
  priority: { type: 'string', description: 'Priority name (optional)' },
262
262
  labels: { type: 'array', items: { type: 'string' }, description: 'Labels to apply (optional)' },
263
263
  fixVersion: { type: 'string', description: 'Fix version name (optional)' },
264
- parent: { type: 'string', description: 'Parent issue key for subtasks (optional)' },
264
+ parent: { type: 'string', description: 'Parent issue key. For Sub-task issue types this sets the Jira parent. If the key points to an Epic, the Epic Link custom field is set automatically instead (Jira Server epic membership).' },
265
+ epicLink: { type: 'string', description: 'Epic issue key to attach the new issue to as an epic child (Jira Server Epic Link). Overrides parent if both are passed.' },
265
266
  },
266
267
  required: ['issueType', 'summary'],
267
268
  },
@@ -274,6 +275,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
274
275
  priority: { type: 'string', description: 'New priority name (optional)' },
275
276
  labels: { type: 'array', items: { type: 'string' }, description: 'Replace label set (pass [] to clear)' },
276
277
  fixVersion: { type: 'string', description: 'Fix version name, or empty string to clear (optional)' },
278
+ epicLink: { type: 'string', description: 'Epic issue key to set as parent epic (Jira Server Epic Link), or empty string to clear.' },
277
279
  },
278
280
  },
279
281
  sprintId: { type: 'number', description: 'Sprint ID to add the issue into (optional)' },
@@ -402,7 +404,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
402
404
  },
403
405
  {
404
406
  name: 'bitbucket_mutate',
405
- description: 'Use when asked to "open a PR for this branch", "create a pull request", "approve this PR", "merge it", "ship it", or "decline this PR". Auto-targets the open PR for the current branch when prId is omitted. Handles create, update, approve/unapprove, decline, and merge in one call.',
407
+ description: 'Use when asked to "open a PR for this branch", "create a pull request", "approve this PR", "request changes / mark needs work", "merge it", "ship it", or "decline this PR". Auto-targets the open PR for the current branch when prId is omitted. Handles create, update, approve/unapprove, needs_work, decline, and merge in one call. needs_work sets your reviewer status to "Needs work" (Bitbucket Server\'s changes-requested signal); revert with action=unapprove.',
406
408
  inputSchema: {
407
409
  type: 'object',
408
410
  properties: {
@@ -411,7 +413,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
411
413
  repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
412
414
  repo: { type: 'string', description: 'Alias for repoSlug' },
413
415
  prId: { type: 'number', description: 'Target PR number (optional, auto-resolved from branch)' },
414
- action: { type: 'string', enum: ['approve', 'unapprove', 'decline', 'merge'], description: 'Lifecycle action to perform (optional)' },
416
+ action: { type: 'string', enum: ['approve', 'unapprove', 'needs_work', 'decline', 'merge'], description: 'Lifecycle action to perform (optional). needs_work = mark your reviewer status as "Needs work" (changes requested).' },
415
417
  mergeStrategy: { type: 'string', enum: ['MERGE_COMMIT', 'SQUASH', 'FAST_FORWARD'], description: 'Merge strategy (action=merge only)' },
416
418
  mergeMessage: { type: 'string', description: 'Custom merge commit message (action=merge only)' },
417
419
  declineMessage: { type: 'string', description: 'Decline message (action=decline only)' },
@@ -847,6 +849,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
847
849
  return await bitbucket.approvePr(a);
848
850
  if (action === 'unapprove')
849
851
  return await bitbucket.unapprovePr(a);
852
+ if (action === 'needs_work')
853
+ return await bitbucket.needsWorkPr(a);
850
854
  if (action === 'decline')
851
855
  return await bitbucket.declinePr({ ...a, message: a.declineMessage });
852
856
  if (action === 'merge')
package/dist/jira.js CHANGED
@@ -97,6 +97,8 @@ export class JiraClient {
97
97
  currentUserCache;
98
98
  projectsCache;
99
99
  issueLinkingEnabled;
100
+ epicLinkFieldIdCache;
101
+ issueTypeCache = new Map();
100
102
  constructor(baseUrl, token) {
101
103
  this.baseUrl = baseUrl.replace(/\/$/, '');
102
104
  this.headers = {
@@ -128,7 +130,17 @@ export class JiraClient {
128
130
  const details = parseJiraErrorDetails(errText);
129
131
  throw new Error(formatJiraError(res.status, method, path, details));
130
132
  }
131
- return res.status === 204 ? null : res.json();
133
+ if (res.status === 204)
134
+ return null;
135
+ const raw = await res.text();
136
+ if (!raw)
137
+ return null;
138
+ try {
139
+ return JSON.parse(raw);
140
+ }
141
+ catch {
142
+ return null;
143
+ }
132
144
  }
133
145
  async request(method, path, body) {
134
146
  return this.requestWithBase('/rest/api/2', method, path, body);
@@ -159,6 +171,24 @@ export class JiraClient {
159
171
  this.issueLinkingEnabled = config?.issueLinkingEnabled ?? false;
160
172
  return this.issueLinkingEnabled;
161
173
  }
174
+ async getEpicLinkFieldId() {
175
+ if (this.epicLinkFieldIdCache !== undefined)
176
+ return this.epicLinkFieldIdCache;
177
+ const fields = (await this.request('GET', '/field')) ?? [];
178
+ const match = fields.find((f) => f.schema?.custom === 'com.pyxis.greenhopper.jira:gh-epic-link');
179
+ this.epicLinkFieldIdCache = match?.id ?? null;
180
+ return this.epicLinkFieldIdCache;
181
+ }
182
+ async getIssueType(issueKey) {
183
+ const cached = this.issueTypeCache.get(issueKey);
184
+ if (cached)
185
+ return cached;
186
+ const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}?fields=issuetype`);
187
+ const name = data?.fields?.issuetype?.name ?? null;
188
+ if (name)
189
+ this.issueTypeCache.set(issueKey, name);
190
+ return name;
191
+ }
162
192
  async assertOwnComment(comment) {
163
193
  const me = await this.getCurrentUser();
164
194
  const commentAuthorName = this.normalizeIdentity(comment.author.name);
@@ -200,8 +230,23 @@ export class JiraClient {
200
230
  fields.labels = args.labels;
201
231
  if (args.fixVersion)
202
232
  fields.fixVersions = [{ name: args.fixVersion }];
203
- if (args.parent)
204
- fields.parent = { key: args.parent };
233
+ let epicLinkTarget = args.epicLink?.trim() || undefined;
234
+ if (args.parent) {
235
+ const parentType = await this.getIssueType(args.parent);
236
+ if (parentType === 'Epic') {
237
+ epicLinkTarget = epicLinkTarget ?? args.parent;
238
+ }
239
+ else {
240
+ fields.parent = { key: args.parent };
241
+ }
242
+ }
243
+ if (epicLinkTarget) {
244
+ const epicFieldId = await this.getEpicLinkFieldId();
245
+ if (!epicFieldId) {
246
+ throw new Error('Epic Link custom field not found on this Jira instance. Set it manually in the Jira UI.');
247
+ }
248
+ fields[epicFieldId] = epicLinkTarget;
249
+ }
205
250
  return this.request('POST', '/issue', { fields });
206
251
  }
207
252
  async updateIssueFieldsInternal(args) {
@@ -218,6 +263,13 @@ export class JiraClient {
218
263
  fields.labels = args.labels;
219
264
  if (args.fixVersion !== undefined)
220
265
  fields.fixVersions = args.fixVersion ? [{ name: args.fixVersion }] : [];
266
+ if (args.epicLink !== undefined) {
267
+ const epicFieldId = await this.getEpicLinkFieldId();
268
+ if (!epicFieldId) {
269
+ throw new Error('Epic Link custom field not found on this Jira instance. Set it manually in the Jira UI.');
270
+ }
271
+ fields[epicFieldId] = args.epicLink ? args.epicLink : null;
272
+ }
221
273
  if (Object.keys(fields).length === 0)
222
274
  return false;
223
275
  await this.request('PUT', `/issue/${encodeURIComponent(args.issueKey)}`, { fields });
@@ -401,11 +453,16 @@ export class JiraClient {
401
453
  const includeSprint = args.includeSprint ?? true;
402
454
  const commentsMaxResults = args.commentsMaxResults ?? 10;
403
455
  const commentsStartAt = args.commentsStartAt ?? 0;
404
- const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks,attachment';
456
+ const baseFields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks,attachment';
457
+ const epicFieldId = await this.getEpicLinkFieldId();
458
+ const fields = epicFieldId ? `${baseFields},${epicFieldId}` : baseFields;
405
459
  const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
406
460
  if (!issue)
407
461
  return text('Issue not found.');
408
462
  const f = issue.fields;
463
+ const epicLinkKey = epicFieldId && typeof f[epicFieldId] === 'string'
464
+ ? f[epicFieldId]
465
+ : undefined;
409
466
  const lines = [
410
467
  `Issue: ${issue.key} — ${f.summary}`,
411
468
  `URL: ${this.issueUrl(issue.key)}`,
@@ -416,6 +473,7 @@ export class JiraClient {
416
473
  `Labels: ${f.labels?.join(', ') || 'None'}`,
417
474
  `Components: ${f.components?.map((c) => c.name).join(', ') || 'None'}`,
418
475
  ...(f.parent ? [`Parent: [${f.parent.key}] ${f.parent.fields.summary} (${f.parent.fields.issuetype.name})`] : []),
476
+ ...(epicLinkKey ? [`Epic Link: ${epicLinkKey}`] : []),
419
477
  ...(f.fixVersions?.length ? [`Fix Vers: ${f.fixVersions.map((v) => v.name).join(', ')}`] : []),
420
478
  ...(f.subtasks?.length ? [`Subtasks: ${f.subtasks.map((s) => `[${s.key}] ${s.fields.summary} (${s.fields.status.name})`).join(', ')}`] : []),
421
479
  ...(f.issuelinks?.length ? [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",