@aaronsb/jira-cloud-mcp 0.3.0 → 0.4.0

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
@@ -2,9 +2,19 @@
2
2
 
3
3
  A Model Context Protocol server for interacting with Jira Cloud instances.
4
4
 
5
- ## Quick Start
5
+ ## Install
6
6
 
7
- Add to your MCP settings:
7
+ ### Claude Desktop (one-click)
8
+
9
+ Download [`jira-cloud-mcp.mcpb`](https://github.com/aaronsb/jira-cloud/releases/latest) and open it — Claude Desktop will prompt for your Jira credentials.
10
+
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add jira-cloud -e JIRA_API_TOKEN=your-token -e JIRA_EMAIL=your-email -e JIRA_HOST=https://your-team.atlassian.net -- npx -y @aaronsb/jira-cloud-mcp
15
+ ```
16
+
17
+ ### Manual (any MCP client)
8
18
 
9
19
  ```json
10
20
  {
@@ -22,12 +32,6 @@ Add to your MCP settings:
22
32
  }
23
33
  ```
24
34
 
25
- Or install globally:
26
-
27
- ```bash
28
- npm install -g @aaronsb/jira-cloud-mcp
29
- ```
30
-
31
35
  ### Credentials
32
36
 
33
37
  Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/manage/api-tokens).
@@ -175,6 +175,153 @@ export class JiraClient {
175
175
  }
176
176
  return issueDetails;
177
177
  }
178
+ /** Lightweight fetch: key, summary, issuetype, status, parent only */
179
+ async fetchNodeFields(issueKey) {
180
+ const issue = await this.client.issues.getIssue({
181
+ issueIdOrKey: issueKey,
182
+ fields: ['summary', 'issuetype', 'status', 'parent'],
183
+ });
184
+ const f = issue.fields;
185
+ return {
186
+ key: issue.key,
187
+ summary: f?.summary || '',
188
+ issueType: f?.issuetype?.name || '',
189
+ status: f?.status?.name || '',
190
+ parent: f?.parent?.key || null,
191
+ };
192
+ }
193
+ /** Fetch children of given keys in one JQL query. Returns truncated flag if results hit the limit. */
194
+ async fetchChildren(parentKeys) {
195
+ if (parentKeys.length === 0)
196
+ return { children: [], truncated: false };
197
+ const maxResults = 100;
198
+ const jql = `parent in (${parentKeys.join(', ')})`;
199
+ const results = await this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({
200
+ jql,
201
+ maxResults,
202
+ fields: ['summary', 'issuetype', 'status', 'parent'],
203
+ });
204
+ const issues = results.issues || [];
205
+ return {
206
+ children: issues.map((issue) => ({
207
+ key: issue.key,
208
+ summary: issue.fields?.summary || '',
209
+ issueType: issue.fields?.issuetype?.name || '',
210
+ status: issue.fields?.status?.name || '',
211
+ parent: issue.fields?.parent?.key || '',
212
+ })),
213
+ truncated: issues.length >= maxResults,
214
+ };
215
+ }
216
+ /**
217
+ * Traverse the issue hierarchy: walk up `upHops` levels and down `downHops` levels
218
+ * from the focus issue, returning a tree with a "you are here" marker.
219
+ */
220
+ async getHierarchy(issueKey, upHops = 4, downHops = 4) {
221
+ // 1. Fetch focus node
222
+ const focus = await this.fetchNodeFields(issueKey);
223
+ // 2. Walk UP the parent chain (with circular reference guard)
224
+ const ancestors = [];
225
+ const visited = new Set([focus.key]);
226
+ let current = focus;
227
+ for (let i = 0; i < upHops; i++) {
228
+ if (!current.parent || visited.has(current.parent))
229
+ break;
230
+ visited.add(current.parent);
231
+ const parentNode = await this.fetchNodeFields(current.parent);
232
+ ancestors.unshift(parentNode); // prepend — root first
233
+ current = parentNode;
234
+ }
235
+ // Build the focus node
236
+ const focusNode = {
237
+ key: focus.key,
238
+ summary: focus.summary,
239
+ issueType: focus.issueType,
240
+ status: focus.status,
241
+ children: [],
242
+ };
243
+ // BFS: expand children level by level
244
+ let truncated = false;
245
+ let actualDownDepth = 0;
246
+ let frontier = [focusNode];
247
+ for (let level = 0; level < downHops; level++) {
248
+ const parentKeys = frontier.map(n => n.key);
249
+ const result = await this.fetchChildren(parentKeys);
250
+ if (result.children.length === 0)
251
+ break;
252
+ if (result.truncated)
253
+ truncated = true;
254
+ actualDownDepth = level + 1;
255
+ // Group children by parent
256
+ const byParent = new Map();
257
+ for (const child of result.children) {
258
+ const group = byParent.get(child.parent) || [];
259
+ group.push(child);
260
+ byParent.set(child.parent, group);
261
+ }
262
+ const nextFrontier = [];
263
+ for (const parent of frontier) {
264
+ const childNodes = (byParent.get(parent.key) || []).map(c => ({
265
+ key: c.key,
266
+ summary: c.summary,
267
+ issueType: c.issueType,
268
+ status: c.status,
269
+ children: [],
270
+ }));
271
+ parent.children = childNodes;
272
+ nextFrontier.push(...childNodes);
273
+ }
274
+ frontier = nextFrontier;
275
+ }
276
+ // 4. Build the full tree: ancestors → focus → descendants
277
+ let root = focusNode;
278
+ for (const ancestor of [...ancestors].reverse()) {
279
+ root = {
280
+ key: ancestor.key,
281
+ summary: ancestor.summary,
282
+ issueType: ancestor.issueType,
283
+ status: ancestor.status,
284
+ children: [root],
285
+ };
286
+ }
287
+ // 5. Expand sibling children for ancestor nodes (batched single call)
288
+ if (ancestors.length > 0) {
289
+ // Collect all ancestor keys for a single batched fetch
290
+ const ancestorKeys = [];
291
+ let walkNode = root;
292
+ for (let i = 0; i < ancestors.length; i++) {
293
+ ancestorKeys.push(walkNode.key);
294
+ walkNode = walkNode.children[0];
295
+ }
296
+ const siblingResult = await this.fetchChildren(ancestorKeys);
297
+ if (siblingResult.truncated)
298
+ truncated = true;
299
+ // Group siblings by parent
300
+ const siblingsByParent = new Map();
301
+ for (const child of siblingResult.children) {
302
+ const group = siblingsByParent.get(child.parent) || [];
303
+ group.push(child);
304
+ siblingsByParent.set(child.parent, group);
305
+ }
306
+ // Distribute siblings to each ancestor node
307
+ let node = root;
308
+ for (let i = 0; i < ancestors.length; i++) {
309
+ const existingChildKey = node.children[0]?.key;
310
+ const siblings = (siblingsByParent.get(node.key) || [])
311
+ .filter(c => c.key !== existingChildKey)
312
+ .map(c => ({ key: c.key, summary: c.summary, issueType: c.issueType, status: c.status, children: [] }));
313
+ node.children = [...node.children, ...siblings];
314
+ node = node.children.find(c => c.key === existingChildKey) || node.children[0];
315
+ }
316
+ }
317
+ return {
318
+ root,
319
+ focusKey: focus.key,
320
+ upDepth: ancestors.length,
321
+ downDepth: actualDownDepth,
322
+ truncated,
323
+ };
324
+ }
178
325
  async getIssueAttachments(issueKey) {
179
326
  const issue = await this.client.issues.getIssue({
180
327
  issueIdOrKey: issueKey,
@@ -13,8 +13,8 @@ function validateManageJiraIssueArgs(args) {
13
13
  const normalizedArgs = normalizeArgs(args);
14
14
  // Validate operation parameter
15
15
  if (typeof normalizedArgs.operation !== 'string' ||
16
- !['create', 'get', 'update', 'delete', 'move', 'transition', 'comment', 'link'].includes(normalizedArgs.operation)) {
17
- throw new McpError(ErrorCode.InvalidParams, 'Invalid operation parameter. Valid values are: create, get, update, delete, move, transition, comment, link');
16
+ !['create', 'get', 'update', 'delete', 'move', 'transition', 'comment', 'link', 'hierarchy'].includes(normalizedArgs.operation)) {
17
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid operation parameter. Valid values are: create, get, update, delete, move, transition, comment, link, hierarchy');
18
18
  }
19
19
  // Validate parameters based on operation
20
20
  switch (normalizedArgs.operation) {
@@ -92,6 +92,11 @@ function validateManageJiraIssueArgs(args) {
92
92
  throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid linkType parameter. Please provide a valid link type for the link operation.');
93
93
  }
94
94
  break;
95
+ case 'hierarchy':
96
+ if (typeof normalizedArgs.issueKey !== 'string' || normalizedArgs.issueKey.trim() === '') {
97
+ throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid issueKey parameter. Please provide a valid issue key for the hierarchy operation.');
98
+ }
99
+ break;
95
100
  }
96
101
  // Validate expand parameter
97
102
  if (normalizedArgs.expand !== undefined) {
@@ -332,6 +337,34 @@ async function handleLinkIssue(jiraClient, args) {
332
337
  ],
333
338
  };
334
339
  }
340
+ function renderHierarchyTree(node, focusKey, prefix = '', isLast = true, isRoot = true) {
341
+ const connector = isRoot ? '' : (isLast ? '└─ ' : '├─ ');
342
+ const marker = node.key === focusKey ? ' ← you are here' : '';
343
+ const line = `${prefix}${connector}**${node.key}** ${node.issueType}: ${node.summary} [${node.status}]${marker}`;
344
+ const childPrefix = isRoot ? '' : (prefix + (isLast ? ' ' : '│ '));
345
+ const childLines = node.children.map((child, i) => renderHierarchyTree(child, focusKey, childPrefix, i === node.children.length - 1, false));
346
+ return [line, ...childLines].join('\n');
347
+ }
348
+ async function handleHierarchy(jiraClient, args) {
349
+ const up = Math.min(Math.max(args.up ?? 4, 0), 8);
350
+ const down = Math.min(Math.max(args.down ?? 4, 0), 8);
351
+ console.error(`Fetching hierarchy for ${args.issueKey}: up=${up}, down=${down}`);
352
+ const result = await jiraClient.getHierarchy(args.issueKey, up, down);
353
+ const tree = renderHierarchyTree(result.root, result.focusKey);
354
+ const lines = [
355
+ `# Issue Hierarchy: ${result.focusKey}`,
356
+ '',
357
+ `Traversed ${result.upDepth} level${result.upDepth !== 1 ? 's' : ''} up, ${result.downDepth} level${result.downDepth !== 1 ? 's' : ''} down`,
358
+ ];
359
+ if (result.truncated) {
360
+ lines.push('', '⚠️ Results were truncated — some children may not be shown. Narrow the scope with smaller `up`/`down` values or focus on a specific subtree.');
361
+ }
362
+ lines.push('', tree);
363
+ const summary = lines.join('\n');
364
+ return {
365
+ content: [{ type: 'text', text: summary + issueGuidance('hierarchy', args.issueKey) }],
366
+ };
367
+ }
335
368
  // Main handler function
336
369
  export async function handleIssueRequest(jiraClient, request) {
337
370
  console.error('Handling issue request...');
@@ -382,6 +415,10 @@ export async function handleIssueRequest(jiraClient, request) {
382
415
  console.error('Processing link issue operation');
383
416
  return await handleLinkIssue(jiraClient, normalizedArgs);
384
417
  }
418
+ case 'hierarchy': {
419
+ console.error('Processing hierarchy operation');
420
+ return await handleHierarchy(jiraClient, normalizedArgs);
421
+ }
385
422
  default: {
386
423
  console.error(`Unknown operation: ${normalizedArgs.operation}`);
387
424
  throw new McpError(ErrorCode.MethodNotFound, `Unknown operation: ${normalizedArgs.operation}`);
package/build/index.js CHANGED
@@ -18,7 +18,14 @@ const JIRA_EMAIL = process.env.JIRA_EMAIL;
18
18
  const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
19
19
  const JIRA_HOST = process.env.JIRA_HOST;
20
20
  if (!JIRA_EMAIL || !JIRA_API_TOKEN || !JIRA_HOST) {
21
- throw new Error('Missing required Jira credentials in environment variables');
21
+ const missing = [
22
+ !JIRA_API_TOKEN && 'JIRA_API_TOKEN',
23
+ !JIRA_EMAIL && 'JIRA_EMAIL',
24
+ !JIRA_HOST && 'JIRA_HOST',
25
+ ].filter(Boolean).join(', ');
26
+ console.error(`[jira-cloud] Missing required environment variables: ${missing}`);
27
+ console.error('[jira-cloud] Set these in your MCP configuration or MCPB extension settings.');
28
+ process.exit(1);
22
29
  }
23
30
  const require = createRequire(import.meta.url);
24
31
  const { version } = require('../package.json');
@@ -142,13 +142,13 @@ export const toolSchemas = {
142
142
  },
143
143
  manage_jira_issue: {
144
144
  name: 'manage_jira_issue',
145
- description: 'Get, create, update, delete, move, transition, comment on, or link Jira issues',
145
+ description: 'Get, create, update, delete, move, transition, comment on, link, or explore hierarchy of Jira issues',
146
146
  inputSchema: {
147
147
  type: 'object',
148
148
  properties: {
149
149
  operation: {
150
150
  type: 'string',
151
- enum: ['create', 'get', 'update', 'delete', 'move', 'transition', 'comment', 'link'],
151
+ enum: ['create', 'get', 'update', 'delete', 'move', 'transition', 'comment', 'link', 'hierarchy'],
152
152
  description: 'Operation to perform',
153
153
  },
154
154
  issueKey: {
@@ -216,6 +216,14 @@ export const toolSchemas = {
216
216
  type: 'string',
217
217
  description: 'Target issue type for move (e.g., Story, Bug). Required for move.',
218
218
  },
219
+ up: {
220
+ type: 'number',
221
+ description: 'Hierarchy: how many levels up to traverse (default 4, max 8).',
222
+ },
223
+ down: {
224
+ type: 'number',
225
+ description: 'Hierarchy: how many levels down to traverse (default 4, max 8).',
226
+ },
219
227
  expand: {
220
228
  type: 'array',
221
229
  items: {
@@ -38,6 +38,9 @@ export function issueNextSteps(operation, issueKey) {
38
38
  case 'link':
39
39
  steps.push({ description: 'View the linked issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Read available link types from jira://issue-link-types resource' });
40
40
  break;
41
+ case 'hierarchy':
42
+ steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
43
+ break;
41
44
  }
42
45
  return steps.length > 0 ? formatSteps(steps) : '';
43
46
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
+ "mcpName": "io.github.aaronsb/jira-cloud",
4
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
5
6
  "type": "module",
6
7
  "bin": {
@@ -45,7 +46,7 @@
45
46
  "update-doc-timestamps": "node scripts/update-doc-timestamps.js"
46
47
  },
47
48
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.24.3",
49
+ "@modelcontextprotocol/sdk": "^1.27.1",
49
50
  "jira.js": "5.3.1",
50
51
  "jsdom": "^27.3.0",
51
52
  "markdown-it": "^14.1.0"