@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 +12 -8
- package/build/client/jira-client.js +147 -0
- package/build/handlers/issue-handlers.js +39 -2
- package/build/index.js +8 -1
- package/build/schemas/tool-schemas.js +10 -2
- package/build/utils/next-steps.js +3 -0
- package/package.json +3 -2
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
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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.
|
|
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"
|