@stubbedev/atlassian-mcp 0.1.1 → 0.1.5

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
@@ -1,6 +1,6 @@
1
1
  # atlassian-mcp
2
2
 
3
- A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **self-hosted Jira** (Server / Data Center) and **self-hosted Bitbucket** (Server / Data Center). Exposes 33 tools for natural-language workflows around tickets, pull requests, review threads, and git context.
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **self-hosted Jira** (Server / Data Center) and **self-hosted Bitbucket** (Server / Data Center). Exposes tools for natural-language workflows around tickets, pull requests, review threads, and git context.
4
4
 
5
5
  > **Note:** This server only supports self-hosted instances. Jira Cloud and Bitbucket Cloud use different APIs and are not supported.
6
6
 
@@ -22,9 +22,14 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
22
22
  | `jira_my_issues` | List issues assigned to you, ordered by last updated |
23
23
  | `jira_get_projects` | List all accessible projects |
24
24
  | `jira_get_issue_types` | List issue types and their available statuses for a project |
25
+ | `jira_get_sprints` | List sprints for a board (with sprint IDs for assignment) |
25
26
  | `jira_get_issue` | Get issue details by key |
27
+ | `jira_issue_overview` | Get one-call issue overview (details, transitions, sprint context, optional comments) |
28
+ | `jira_board_overview` | Get one-call board overview (board info, sprints, optional sprint issues) |
26
29
  | `jira_create_issue` | Create a new issue |
27
30
  | `jira_update_issue` | Update summary, description, assignee, or priority |
31
+ | `jira_add_issues_to_sprint` | Add one or more issues to a sprint by sprint ID |
32
+ | `jira_mutate_issue` | Bundle create/update/sprint/transition/comment actions into one call |
28
33
  | `jira_search_users` | Search for users by name or email |
29
34
  | `jira_get_comments` | List comments on an issue |
30
35
  | `jira_add_comment` | Add a comment to an issue |
package/dist/bitbucket.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'child_process';
2
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
2
3
  function safeExec(cmd) {
3
4
  try {
4
5
  return execSync(cmd, { encoding: 'utf-8' }).trim();
@@ -136,6 +137,16 @@ function formatBitbucketError(status, method, path, details) {
136
137
  return `${prefix}. Conflict (often stale version/state). Refresh and retry. ${details}`.trim();
137
138
  return details ? `${prefix}. ${details}` : prefix;
138
139
  }
140
+ function validateCommentText(textValue) {
141
+ const trimmed = textValue.trim();
142
+ if (!trimmed) {
143
+ throw new Error('Bitbucket comment text must not be empty.');
144
+ }
145
+ if (EMOJI_RE.test(trimmed)) {
146
+ throw new Error('Bitbucket comments must not include emoji. Use concise plain text only.');
147
+ }
148
+ return trimmed;
149
+ }
139
150
  export class BitbucketClient {
140
151
  baseUrl;
141
152
  headers;
@@ -510,7 +521,7 @@ export class BitbucketClient {
510
521
  }
511
522
  async addPrComment(args) {
512
523
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
513
- const body = { text: args.text };
524
+ const body = { text: validateCommentText(args.text) };
514
525
  if (args.parentCommentId)
515
526
  body.parent = { id: args.parentCommentId };
516
527
  const created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
@@ -531,7 +542,7 @@ export class BitbucketClient {
531
542
  throw new Error(`Comment #${args.commentId} not found.`);
532
543
  const body = {
533
544
  version: current.version,
534
- text: args.text ?? current.text,
545
+ text: args.text !== undefined ? validateCommentText(args.text) : current.text,
535
546
  };
536
547
  if (args.state)
537
548
  body.state = args.state;
package/dist/index.js CHANGED
@@ -29,6 +29,16 @@ function normalizeJiraProjectArgs(args) {
29
29
  out.projectKey = out.project;
30
30
  return out;
31
31
  }
32
+ function normalizeJiraMutateArgs(args) {
33
+ const out = normalizeJiraProjectArgs(args);
34
+ if (out.create && typeof out.create === 'object') {
35
+ const create = { ...out.create };
36
+ if (typeof create.project === 'string' && typeof create.projectKey !== 'string')
37
+ create.projectKey = create.project;
38
+ out.create = create;
39
+ }
40
+ return out;
41
+ }
32
42
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
43
  tools: [
34
44
  // ── Context ───────────────────────────────────────────────────────────
@@ -92,6 +102,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
92
102
  },
93
103
  },
94
104
  },
105
+ {
106
+ name: 'jira_get_sprints',
107
+ description: 'Use when you need sprint IDs for planning or assignment. Returns sprints for a Jira board.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ boardId: { type: 'number', description: 'Jira board ID' },
112
+ state: { type: 'string', description: 'Optional sprint state filter, e.g. "active", "future", or "closed"' },
113
+ maxResults: { type: 'number', description: 'Max sprints per page (default 20)', default: 20 },
114
+ startAt: { type: 'number', description: 'Offset for pagination (default 0)', default: 0 },
115
+ },
116
+ required: ['boardId'],
117
+ },
118
+ },
95
119
  {
96
120
  name: 'jira_search_users',
97
121
  description: 'Use when assigning tickets and you need to find the correct Jira username by name or email.',
@@ -115,6 +139,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
115
139
  required: ['issueKey'],
116
140
  },
117
141
  },
142
+ {
143
+ name: 'jira_issue_overview',
144
+ description: 'Use when you want one Jira issue snapshot in a single call: details, transitions, sprint context, and optional comments.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ issueKey: { type: 'string', description: 'Jira issue key, e.g. "FOO-123"' },
149
+ includeComments: { type: 'boolean', description: 'Include comments in the overview (default true)', default: true },
150
+ commentsMaxResults: { type: 'number', description: 'Max comments when includeComments=true (default 10)', default: 10 },
151
+ commentsStartAt: { type: 'number', description: 'Comment pagination offset (default 0)', default: 0 },
152
+ includeTransitions: { type: 'boolean', description: 'Include available transitions (default true)', default: true },
153
+ includeSprint: { type: 'boolean', description: 'Include sprint data via Jira Agile API (default true)', default: true },
154
+ },
155
+ required: ['issueKey'],
156
+ },
157
+ },
118
158
  {
119
159
  name: 'jira_create_issue',
120
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.',
@@ -128,6 +168,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
128
168
  description: { type: 'string', description: 'Issue description (optional)' },
129
169
  assignee: { type: 'string', description: 'Username to assign to (optional)' },
130
170
  priority: { type: 'string', description: 'Priority name, e.g. "High" (optional)' },
171
+ sprintId: { type: 'number', description: 'Sprint ID to immediately add the new issue into (optional)' },
131
172
  },
132
173
  required: ['issueType', 'summary'],
133
174
  },
@@ -143,10 +184,79 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
143
184
  description: { type: 'string', description: 'New description (optional)' },
144
185
  assignee: { type: 'string', description: 'New assignee username, or empty string to unassign (optional)' },
145
186
  priority: { type: 'string', description: 'New priority name (optional)' },
187
+ sprintId: { type: 'number', description: 'Sprint ID to add this issue into (optional)' },
146
188
  },
147
189
  required: ['issueKey'],
148
190
  },
149
191
  },
192
+ {
193
+ name: 'jira_add_issues_to_sprint',
194
+ description: 'Use when you want to assign one or more Jira issues to a sprint by sprint ID.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ sprintId: { type: 'number', description: 'Sprint ID' },
199
+ issueKey: { type: 'string', description: 'Single issue key (optional)' },
200
+ issueKeys: { type: 'array', items: { type: 'string' }, description: 'Multiple issue keys (optional)' },
201
+ },
202
+ required: ['sprintId'],
203
+ },
204
+ },
205
+ {
206
+ 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
+ inputSchema: {
209
+ type: 'object',
210
+ properties: {
211
+ issueKey: { type: 'string', description: 'Existing issue key to mutate (optional if create is provided)' },
212
+ create: {
213
+ type: 'object',
214
+ properties: {
215
+ projectKey: { type: 'string', description: 'Jira project code (optional, auto-resolved when omitted)' },
216
+ project: { type: 'string', description: 'Alias for projectKey' },
217
+ issueType: { type: 'string', description: 'Issue type name, e.g. Bug, Story, Task' },
218
+ summary: { type: 'string', description: 'Issue title' },
219
+ description: { type: 'string', description: 'Issue description (optional)' },
220
+ assignee: { type: 'string', description: 'Username to assign to (optional)' },
221
+ priority: { type: 'string', description: 'Priority name (optional)' },
222
+ },
223
+ required: ['issueType', 'summary'],
224
+ },
225
+ update: {
226
+ type: 'object',
227
+ properties: {
228
+ summary: { type: 'string', description: 'New summary (optional)' },
229
+ description: { type: 'string', description: 'New description (optional)' },
230
+ assignee: { type: 'string', description: 'New assignee username, or empty string to unassign (optional)' },
231
+ priority: { type: 'string', description: 'New priority name (optional)' },
232
+ },
233
+ },
234
+ sprintId: { type: 'number', description: 'Sprint ID to add the issue into (optional)' },
235
+ transitionId: { type: 'string', description: 'Transition ID (optional if transitionName is provided)' },
236
+ 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
+ },
239
+ },
240
+ },
241
+ {
242
+ name: 'jira_board_overview',
243
+ description: 'Use when you want one board-level planning snapshot: board info, sprints, and optional sprint issues in one call.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ boardId: { type: 'number', description: 'Jira board ID' },
248
+ sprintState: { type: 'string', description: 'Sprint state filter, e.g. "active,future" (default active,future)' },
249
+ sprintMaxResults: { type: 'number', description: 'Max sprints per page (default 10)', default: 10 },
250
+ sprintStartAt: { type: 'number', description: 'Sprints pagination offset (default 0)', default: 0 },
251
+ includeIssues: { type: 'boolean', description: 'Include sprint issues (default true)', default: true },
252
+ issueMaxResults: { type: 'number', description: 'Max issues per sprint when includeIssues=true (default 25)', default: 25 },
253
+ issueStartAt: { type: 'number', description: 'Issue pagination offset per sprint (default 0)', default: 0 },
254
+ assignee: { type: 'string', description: 'Optional assignee filter for sprint issues' },
255
+ status: { type: 'string', description: 'Optional status filter for sprint issues' },
256
+ },
257
+ required: ['boardId'],
258
+ },
259
+ },
150
260
  {
151
261
  name: 'jira_get_comments',
152
262
  description: 'Use when you want the discussion thread on a Jira ticket, with pagination for long threads.',
@@ -162,12 +272,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
162
272
  },
163
273
  {
164
274
  name: 'jira_add_comment',
165
- description: 'Use when you want to leave a comment on a Jira ticket.',
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.',
166
276
  inputSchema: {
167
277
  type: 'object',
168
278
  properties: {
169
279
  issueKey: { type: 'string', description: 'Jira issue key' },
170
- body: { type: 'string', description: 'Comment text (plain text or Jira wiki markup)' },
280
+ body: { type: 'string', description: 'Concise comment text only. No filler. Do not include emojis.' },
171
281
  },
172
282
  required: ['issueKey', 'body'],
173
283
  },
@@ -406,7 +516,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
406
516
  },
407
517
  {
408
518
  name: 'bitbucket_add_pr_comment',
409
- description: 'Use when you want to add a PR review comment or reply to an existing thread. You can pass projectKey/repoSlug or project/repo.',
519
+ description: 'Use when you want to add a PR review comment or reply to an existing thread. Keep comments concise, plain text, and free of filler. Never include emojis. You can pass projectKey/repoSlug or project/repo.',
410
520
  inputSchema: {
411
521
  type: 'object',
412
522
  properties: {
@@ -416,14 +526,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
416
526
  repo: { type: 'string', description: 'Alias for repoSlug' },
417
527
  prId: { type: 'number', description: 'Pull request number (PR ID)' },
418
528
  parentCommentId: { type: 'number', description: 'Parent comment ID for reply mode (optional)' },
419
- text: { type: 'string', description: 'Comment text' },
529
+ text: { type: 'string', description: 'Concise comment text only. No filler. Do not include emojis.' },
420
530
  },
421
531
  required: ['prId', 'text'],
422
532
  },
423
533
  },
424
534
  {
425
535
  name: 'bitbucket_update_pr_comment',
426
- description: 'Use when you want to edit PR comments, resolve/reopen them, or mark comments as task-style BLOCKER items. You can pass projectKey/repoSlug or project/repo.',
536
+ description: 'Use when you want to edit PR comments, resolve/reopen them, or mark comments as task-style BLOCKER items. Keep comments concise, plain text, and free of filler. Never include emojis. You can pass projectKey/repoSlug or project/repo.',
427
537
  inputSchema: {
428
538
  type: 'object',
429
539
  properties: {
@@ -433,7 +543,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
433
543
  repo: { type: 'string', description: 'Alias for repoSlug' },
434
544
  prId: { type: 'number', description: 'Pull request number (PR ID)' },
435
545
  commentId: { type: 'number', description: 'Comment ID to update' },
436
- text: { type: 'string', description: 'New comment text (optional)' },
546
+ text: { type: 'string', description: 'New concise comment text only. No filler. Do not include emojis. (optional)' },
437
547
  state: { type: 'string', enum: ['OPEN', 'RESOLVED'], description: 'Comment state (optional)' },
438
548
  severity: { type: 'string', enum: ['NORMAL', 'BLOCKER'], description: 'Comment severity (optional). BLOCKER marks it as a task/checklist item.' },
439
549
  },
@@ -542,14 +652,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
542
652
  return await jira.getProjects(args);
543
653
  case 'jira_get_issue_types':
544
654
  return await jira.getIssueTypes(normalizeJiraProjectArgs(args));
655
+ case 'jira_get_sprints':
656
+ return await jira.getSprints(args);
545
657
  case 'jira_search_users':
546
658
  return await jira.searchUsers(args);
547
659
  case 'jira_get_issue':
548
660
  return await jira.getIssue(args);
661
+ case 'jira_issue_overview':
662
+ return await jira.issueOverview(args);
549
663
  case 'jira_create_issue':
550
664
  return await jira.createIssue(normalizeJiraProjectArgs(args));
551
665
  case 'jira_update_issue':
552
666
  return await jira.updateIssue(args);
667
+ case 'jira_add_issues_to_sprint':
668
+ return await jira.addIssuesToSprint(args);
669
+ case 'jira_mutate_issue':
670
+ return await jira.mutateIssue(normalizeJiraMutateArgs(args));
671
+ case 'jira_board_overview':
672
+ return await jira.boardOverview(args);
553
673
  case 'jira_get_comments':
554
674
  return await jira.getComments(args);
555
675
  case 'jira_add_comment':
package/dist/jira.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  const JIRA_KEY_IN_BRANCH_RE = /\b([A-Z][A-Z0-9]+)-\d+\b/;
3
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
3
4
  function text(t) {
4
5
  return { content: [{ type: 'text', text: t }] };
5
6
  }
@@ -76,6 +77,16 @@ function formatJiraError(status, method, path, details) {
76
77
  return `${prefix}. Conflict. Refresh and retry. ${details}`.trim();
77
78
  return details ? `${prefix}. ${details}` : prefix;
78
79
  }
80
+ function validateCommentBody(body) {
81
+ const trimmed = body.trim();
82
+ if (!trimmed) {
83
+ throw new Error('Jira comment body must not be empty.');
84
+ }
85
+ if (EMOJI_RE.test(trimmed)) {
86
+ throw new Error('Jira comments must not include emoji. Use concise plain text only.');
87
+ }
88
+ return trimmed;
89
+ }
79
90
  export class JiraClient {
80
91
  baseUrl;
81
92
  headers;
@@ -87,8 +98,8 @@ export class JiraClient {
87
98
  Accept: 'application/json',
88
99
  };
89
100
  }
90
- async request(method, path, body) {
91
- const url = `${this.baseUrl}/rest/api/2${path}`;
101
+ async requestWithBase(apiBase, method, path, body) {
102
+ const url = `${this.baseUrl}${apiBase}${path}`;
92
103
  const opts = { method, headers: this.headers };
93
104
  if (body !== undefined)
94
105
  opts.body = JSON.stringify(body);
@@ -100,6 +111,67 @@ export class JiraClient {
100
111
  }
101
112
  return res.status === 204 ? null : res.json();
102
113
  }
114
+ async request(method, path, body) {
115
+ return this.requestWithBase('/rest/api/2', method, path, body);
116
+ }
117
+ async requestAgile(method, path, body) {
118
+ return this.requestWithBase('/rest/agile/1.0', method, path, body);
119
+ }
120
+ async addIssuesToSprintInternal(sprintId, issueKeys) {
121
+ await this.requestAgile('POST', `/sprint/${sprintId}/issue`, { issues: issueKeys });
122
+ }
123
+ async createIssueInternal(args) {
124
+ const projectKey = await this.resolveProjectKey(args.projectKey);
125
+ const fields = {
126
+ project: { key: projectKey },
127
+ issuetype: { name: args.issueType },
128
+ summary: args.summary,
129
+ };
130
+ if (args.description)
131
+ fields.description = args.description;
132
+ if (args.assignee)
133
+ fields.assignee = { name: args.assignee };
134
+ if (args.priority)
135
+ fields.priority = { name: args.priority };
136
+ return this.request('POST', '/issue', { fields });
137
+ }
138
+ async updateIssueFieldsInternal(args) {
139
+ const fields = {};
140
+ if (args.summary !== undefined)
141
+ fields.summary = args.summary;
142
+ if (args.description !== undefined)
143
+ fields.description = args.description;
144
+ if (args.assignee !== undefined)
145
+ fields.assignee = { name: args.assignee };
146
+ if (args.priority !== undefined)
147
+ fields.priority = { name: args.priority };
148
+ if (Object.keys(fields).length === 0)
149
+ return false;
150
+ await this.request('PUT', `/issue/${args.issueKey}`, { fields });
151
+ return true;
152
+ }
153
+ async resolveTransitionId(issueKey, transitionId, transitionName) {
154
+ if (transitionId)
155
+ return transitionId;
156
+ const requestedName = transitionName?.trim();
157
+ if (!requestedName) {
158
+ throw new Error('Provide transitionId or transitionName');
159
+ }
160
+ const data = await this.request('GET', `/issue/${issueKey}/transitions`);
161
+ const transitions = data?.transitions ?? [];
162
+ const lowered = requestedName.toLowerCase();
163
+ const match = transitions.find((t) => t.name.toLowerCase() === lowered);
164
+ if (!match) {
165
+ const available = transitions.map((t) => t.name).join(', ') || '(none)';
166
+ throw new Error(`Transition "${requestedName}" not found for ${issueKey}. Available: ${available}`);
167
+ }
168
+ return match.id;
169
+ }
170
+ async transitionIssueInternal(issueKey, transitionId) {
171
+ await this.request('POST', `/issue/${issueKey}/transitions`, {
172
+ transition: { id: transitionId },
173
+ });
174
+ }
103
175
  async resolveProjectKey(projectKey) {
104
176
  if (projectKey)
105
177
  return projectKey;
@@ -167,6 +239,26 @@ export class JiraClient {
167
239
  });
168
240
  return text(`Issue types and statuses for ${projectKey}:\n${lines.join('\n')}`);
169
241
  }
242
+ async getSprints(args) {
243
+ const { boardId, maxResults = 20, startAt = 0 } = args;
244
+ const params = new URLSearchParams({
245
+ maxResults: String(maxResults),
246
+ startAt: String(startAt),
247
+ });
248
+ if (args.state)
249
+ params.set('state', args.state);
250
+ const data = await this.requestAgile('GET', `/board/${boardId}/sprint?${params}`);
251
+ if (!data || data.values.length === 0)
252
+ return text(`No sprints found for board ${boardId}.`);
253
+ const lines = data.values.map((s, i) => {
254
+ const window = [s.startDate?.slice(0, 10), s.endDate?.slice(0, 10)].filter(Boolean).join(' -> ');
255
+ const goal = s.goal?.trim() ? ` | Goal: ${s.goal}` : '';
256
+ return `${startAt + i + 1}. [${s.id}] ${s.name} | ${s.state}${window ? ` | ${window}` : ''}${goal}`;
257
+ });
258
+ const rangeEnd = startAt + data.values.length;
259
+ const page = data.isLast ? '' : ` (showing ${startAt + 1}-${rangeEnd}, use startAt=${rangeEnd} for next page)`;
260
+ return text(`Sprints for board ${boardId}${page}:\n${lines.join('\n')}`);
261
+ }
170
262
  async searchUsers(args) {
171
263
  const params = new URLSearchParams({
172
264
  username: args.query,
@@ -200,38 +292,219 @@ export class JiraClient {
200
292
  ];
201
293
  return text(lines.join('\n'));
202
294
  }
203
- async createIssue(args) {
204
- const projectKey = await this.resolveProjectKey(args.projectKey);
205
- const fields = {
206
- project: { key: projectKey },
207
- issuetype: { name: args.issueType },
208
- summary: args.summary,
209
- };
210
- if (args.description)
211
- fields.description = args.description;
295
+ async issueOverview(args) {
296
+ const includeComments = args.includeComments ?? true;
297
+ const includeTransitions = args.includeTransitions ?? true;
298
+ const includeSprint = args.includeSprint ?? true;
299
+ const commentsMaxResults = args.commentsMaxResults ?? 10;
300
+ const commentsStartAt = args.commentsStartAt ?? 0;
301
+ const fields = 'summary,description,status,assignee,priority,issuetype,labels,components';
302
+ const issue = await this.request('GET', `/issue/${args.issueKey}?fields=${fields}`);
303
+ if (!issue)
304
+ return text('Issue not found.');
305
+ const f = issue.fields;
306
+ const lines = [
307
+ `Issue: ${issue.key} — ${f.summary}`,
308
+ `Status: ${f.status.name}`,
309
+ `Type: ${f.issuetype.name}`,
310
+ `Priority: ${f.priority?.name ?? 'None'}`,
311
+ `Assignee: ${f.assignee?.displayName ?? 'Unassigned'}`,
312
+ `Labels: ${f.labels?.join(', ') || 'None'}`,
313
+ `Components: ${f.components?.map((c) => c.name).join(', ') || 'None'}`,
314
+ ];
315
+ if (includeSprint) {
316
+ try {
317
+ const agileIssue = await this.requestAgile('GET', `/issue/${args.issueKey}?fields=sprint,closedSprints`);
318
+ const activeSprint = agileIssue?.fields?.sprint;
319
+ const closedSprints = agileIssue?.fields?.closedSprints ?? [];
320
+ if (activeSprint) {
321
+ lines.push(`Sprint: [${activeSprint.id}] ${activeSprint.name} (${activeSprint.state})`);
322
+ }
323
+ else {
324
+ lines.push('Sprint: None');
325
+ }
326
+ if (closedSprints.length > 0) {
327
+ const closed = closedSprints.slice(0, 5).map((s) => `[${s.id}] ${s.name}`).join(', ');
328
+ const more = closedSprints.length > 5 ? ` (+${closedSprints.length - 5} more)` : '';
329
+ lines.push(`History: ${closed}${more}`);
330
+ }
331
+ }
332
+ catch (err) {
333
+ lines.push(`Sprint: unavailable (${err.message})`);
334
+ }
335
+ }
336
+ if (includeTransitions) {
337
+ const transitions = await this.request('GET', `/issue/${args.issueKey}/transitions`);
338
+ const names = (transitions?.transitions ?? []).map((t) => `${t.name} -> ${t.to.name}`);
339
+ lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
340
+ }
341
+ lines.push('', 'Description:', f.description ?? '(no description)');
342
+ if (includeComments) {
343
+ const comments = await this.request('GET', `/issue/${args.issueKey}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
344
+ const items = comments?.comments ?? [];
345
+ const total = comments?.total ?? 0;
346
+ const page = comments ? pagination(total, commentsStartAt, items.length) : '';
347
+ lines.push('', `Comments: ${total}${page}`);
348
+ if (items.length === 0) {
349
+ lines.push('(none)');
350
+ }
351
+ else {
352
+ for (const c of items) {
353
+ const date = c.created.slice(0, 10);
354
+ lines.push(`--- ${c.author.displayName} (${date}) ---`, c.body, '');
355
+ }
356
+ }
357
+ }
358
+ return text(lines.join('\n').trimEnd());
359
+ }
360
+ async boardOverview(args) {
361
+ const includeIssues = args.includeIssues ?? true;
362
+ const sprintMaxResults = args.sprintMaxResults ?? 10;
363
+ const sprintStartAt = args.sprintStartAt ?? 0;
364
+ const issueMaxResults = args.issueMaxResults ?? 25;
365
+ const issueStartAt = args.issueStartAt ?? 0;
366
+ const board = await this.requestAgile('GET', `/board/${args.boardId}`);
367
+ const sprintParams = new URLSearchParams({
368
+ maxResults: String(sprintMaxResults),
369
+ startAt: String(sprintStartAt),
370
+ });
371
+ if (args.sprintState) {
372
+ sprintParams.set('state', args.sprintState);
373
+ }
374
+ else {
375
+ sprintParams.set('state', 'active,future');
376
+ }
377
+ const sprints = await this.requestAgile('GET', `/board/${args.boardId}/sprint?${sprintParams}`);
378
+ if (!sprints || sprints.values.length === 0) {
379
+ return text(`Board ${args.boardId}${board?.name ? ` (${board.name})` : ''}: no matching sprints.`);
380
+ }
381
+ const issueFilterClauses = [];
212
382
  if (args.assignee)
213
- fields.assignee = { name: args.assignee };
214
- if (args.priority)
215
- fields.priority = { name: args.priority };
216
- const data = await this.request('POST', '/issue', { fields });
383
+ issueFilterClauses.push(`assignee = ${JSON.stringify(args.assignee)}`);
384
+ if (args.status)
385
+ issueFilterClauses.push(`status = ${JSON.stringify(args.status)}`);
386
+ const issueJql = issueFilterClauses.length > 0 ? issueFilterClauses.join(' AND ') : '';
387
+ const sprintIssueData = includeIssues
388
+ ? await Promise.all(sprints.values.map(async (sprint) => {
389
+ const params = new URLSearchParams({
390
+ maxResults: String(issueMaxResults),
391
+ startAt: String(issueStartAt),
392
+ fields: 'summary,status,assignee,priority,issuetype',
393
+ });
394
+ if (issueJql)
395
+ params.set('jql', issueJql);
396
+ const issues = await this.requestAgile('GET', `/sprint/${sprint.id}/issue?${params}`);
397
+ return { sprintId: sprint.id, issues };
398
+ }))
399
+ : [];
400
+ const issueBySprint = new Map(sprintIssueData.map((entry) => [entry.sprintId, entry.issues]));
401
+ const lines = [
402
+ `Board: [${args.boardId}] ${board?.name ?? '(unknown)'} | ${board?.type ?? '(unknown type)'}`,
403
+ `Sprints: ${sprints.values.length}`,
404
+ '',
405
+ ];
406
+ sprints.values.forEach((sprint, idx) => {
407
+ const window = [sprint.startDate?.slice(0, 10), sprint.endDate?.slice(0, 10)].filter(Boolean).join(' -> ');
408
+ lines.push(`${sprintStartAt + idx + 1}. [${sprint.id}] ${sprint.name} | ${sprint.state}${window ? ` | ${window}` : ''}`);
409
+ if (sprint.goal?.trim()) {
410
+ lines.push(` Goal: ${sprint.goal}`);
411
+ }
412
+ if (includeIssues) {
413
+ const issueData = issueBySprint.get(sprint.id);
414
+ const issues = issueData?.issues ?? [];
415
+ lines.push(` Issues: ${issueData?.total ?? 0}`);
416
+ for (const issue of issues) {
417
+ const assignee = issue.fields.assignee?.displayName ?? 'Unassigned';
418
+ lines.push(` - [${issue.key}] ${issue.fields.summary} | ${issue.fields.status.name} | ${assignee}`);
419
+ }
420
+ if ((issueData?.total ?? 0) > issues.length) {
421
+ lines.push(` ...and ${(issueData?.total ?? 0) - issues.length} more (adjust issueStartAt/issueMaxResults).`);
422
+ }
423
+ }
424
+ lines.push('');
425
+ });
426
+ const sprintRangeEnd = sprintStartAt + sprints.values.length;
427
+ if (!sprints.isLast) {
428
+ lines.push(`More sprints available: use sprintStartAt=${sprintRangeEnd}.`);
429
+ }
430
+ return text(lines.join('\n').trimEnd());
431
+ }
432
+ async createIssue(args) {
433
+ const data = await this.createIssueInternal(args);
217
434
  if (!data)
218
435
  return text('Issue created.');
436
+ if (args.sprintId !== undefined) {
437
+ await this.addIssuesToSprintInternal(args.sprintId, [data.key]);
438
+ return text(`Created ${data.key} and added it to sprint ${args.sprintId}.`);
439
+ }
219
440
  return text(`Created ${data.key}.`);
220
441
  }
221
442
  async updateIssue(args) {
222
- const fields = {};
223
- if (args.summary !== undefined)
224
- fields.summary = args.summary;
225
- if (args.description !== undefined)
226
- fields.description = args.description;
227
- if (args.assignee !== undefined)
228
- fields.assignee = { name: args.assignee };
229
- if (args.priority !== undefined)
230
- fields.priority = { name: args.priority };
231
- if (Object.keys(fields).length === 0)
443
+ const hasFieldUpdates = await this.updateIssueFieldsInternal(args);
444
+ if (!hasFieldUpdates && args.sprintId === undefined)
232
445
  return text('Nothing to update.');
233
- await this.request('PUT', `/issue/${args.issueKey}`, { fields });
234
- return text(`Updated ${args.issueKey}.`);
446
+ if (args.sprintId !== undefined) {
447
+ await this.addIssuesToSprintInternal(args.sprintId, [args.issueKey]);
448
+ }
449
+ if (hasFieldUpdates && args.sprintId !== undefined) {
450
+ return text(`Updated ${args.issueKey} and added it to sprint ${args.sprintId}.`);
451
+ }
452
+ if (hasFieldUpdates) {
453
+ return text(`Updated ${args.issueKey}.`);
454
+ }
455
+ return text(`Added ${args.issueKey} to sprint ${args.sprintId}.`);
456
+ }
457
+ async addIssuesToSprint(args) {
458
+ const keys = new Set();
459
+ if (args.issueKey?.trim())
460
+ keys.add(args.issueKey.trim());
461
+ for (const issueKey of args.issueKeys ?? []) {
462
+ const trimmed = issueKey.trim();
463
+ if (trimmed)
464
+ keys.add(trimmed);
465
+ }
466
+ if (keys.size === 0) {
467
+ throw new Error('Provide issueKey or issueKeys with at least one Jira issue key.');
468
+ }
469
+ const issueKeys = Array.from(keys);
470
+ await this.addIssuesToSprintInternal(args.sprintId, issueKeys);
471
+ return text(`Added ${issueKeys.length} issue(s) to sprint ${args.sprintId}.`);
472
+ }
473
+ async mutateIssue(args) {
474
+ let issueKey = args.issueKey?.trim();
475
+ const actions = [];
476
+ if (args.create) {
477
+ const created = await this.createIssueInternal(args.create);
478
+ if (!created)
479
+ throw new Error('Issue creation did not return an issue key.');
480
+ issueKey = created.key;
481
+ actions.push('created issue');
482
+ }
483
+ if (!issueKey) {
484
+ throw new Error('Provide issueKey, or provide create with issueType and summary.');
485
+ }
486
+ if (args.update) {
487
+ const updated = await this.updateIssueFieldsInternal({ issueKey, ...args.update });
488
+ if (updated)
489
+ actions.push('updated fields');
490
+ }
491
+ if (args.sprintId !== undefined) {
492
+ await this.addIssuesToSprintInternal(args.sprintId, [issueKey]);
493
+ actions.push(`added to sprint ${args.sprintId}`);
494
+ }
495
+ if (args.transitionId || args.transitionName) {
496
+ const transitionId = await this.resolveTransitionId(issueKey, args.transitionId, args.transitionName);
497
+ await this.transitionIssueInternal(issueKey, transitionId);
498
+ actions.push(`transitioned via ${transitionId}`);
499
+ }
500
+ if (args.comment !== undefined) {
501
+ await this.request('POST', `/issue/${issueKey}/comment`, { body: validateCommentBody(args.comment) });
502
+ actions.push('added comment');
503
+ }
504
+ if (actions.length === 0) {
505
+ return text('Nothing to mutate.');
506
+ }
507
+ return text(`Mutated ${issueKey}: ${actions.join(', ')}.`);
235
508
  }
236
509
  async getComments(args) {
237
510
  const { issueKey, maxResults = 50, startAt = 0 } = args;
@@ -246,29 +519,12 @@ export class JiraClient {
246
519
  return text(`${data.total} comment(s) on ${issueKey}${page}:\n\n${blocks.join('\n\n')}`);
247
520
  }
248
521
  async addComment(args) {
249
- await this.request('POST', `/issue/${args.issueKey}/comment`, { body: args.body });
522
+ await this.request('POST', `/issue/${args.issueKey}/comment`, { body: validateCommentBody(args.body) });
250
523
  return text(`Comment added to ${args.issueKey}.`);
251
524
  }
252
525
  async transitionIssue(args) {
253
- let transitionId = args.transitionId;
254
- if (!transitionId) {
255
- const requestedName = args.transitionName?.trim();
256
- if (!requestedName) {
257
- throw new Error('Provide transitionId or transitionName');
258
- }
259
- const data = await this.request('GET', `/issue/${args.issueKey}/transitions`);
260
- const transitions = data?.transitions ?? [];
261
- const lowered = requestedName.toLowerCase();
262
- const match = transitions.find((t) => t.name.toLowerCase() === lowered);
263
- if (!match) {
264
- const available = transitions.map((t) => t.name).join(', ') || '(none)';
265
- throw new Error(`Transition "${requestedName}" not found for ${args.issueKey}. Available: ${available}`);
266
- }
267
- transitionId = match.id;
268
- }
269
- await this.request('POST', `/issue/${args.issueKey}/transitions`, {
270
- transition: { id: transitionId },
271
- });
526
+ const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
527
+ await this.transitionIssueInternal(args.issueKey, transitionId);
272
528
  return text(`Transitioned ${args.issueKey} using transition ${transitionId}.`);
273
529
  }
274
530
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",