@aaronsb/jira-cloud-mcp 0.5.6 → 0.5.9
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/build/client/jira-client.js +67 -2
- package/build/handlers/analysis-handler.js +9 -6
- package/build/handlers/filter-handlers.js +54 -6
- package/build/handlers/issue-handlers.js +2 -1
- package/build/index.js +14 -0
- package/build/mcp/markdown-renderer.js +14 -0
- package/build/utils/next-steps.js +11 -1
- package/package.json +1 -1
|
@@ -134,7 +134,7 @@ export class JiraClient {
|
|
|
134
134
|
people: people.length > 0 ? people : undefined,
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
|
-
async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta) {
|
|
137
|
+
async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta, includeHistory = false) {
|
|
138
138
|
const fields = [...this.issueFields];
|
|
139
139
|
if (includeAttachments) {
|
|
140
140
|
fields.push('attachment');
|
|
@@ -147,13 +147,32 @@ export class JiraClient {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
const expands = [];
|
|
151
|
+
if (includeComments)
|
|
152
|
+
expands.push('comments');
|
|
153
|
+
if (includeHistory)
|
|
154
|
+
expands.push('changelog');
|
|
150
155
|
const params = {
|
|
151
156
|
issueIdOrKey: issueKey,
|
|
152
157
|
fields,
|
|
153
|
-
expand:
|
|
158
|
+
expand: expands.length > 0 ? expands.join(',') : undefined,
|
|
154
159
|
};
|
|
155
160
|
const issue = await this.client.issues.getIssue(params);
|
|
156
161
|
const issueDetails = this.mapIssueFields(issue);
|
|
162
|
+
// Extract status transitions from changelog
|
|
163
|
+
if (includeHistory && issue.changelog?.histories) {
|
|
164
|
+
const histories = issue.changelog.histories;
|
|
165
|
+
issueDetails.statusHistory = histories
|
|
166
|
+
.flatMap(h => h.items
|
|
167
|
+
.filter(item => item.field === 'status')
|
|
168
|
+
.map(item => ({
|
|
169
|
+
date: h.created,
|
|
170
|
+
from: item.fromString || '',
|
|
171
|
+
to: item.toString || '',
|
|
172
|
+
author: h.author?.displayName || 'Unknown',
|
|
173
|
+
})))
|
|
174
|
+
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
175
|
+
}
|
|
157
176
|
// Extract custom field values using catalog metadata
|
|
158
177
|
if (customFieldMeta) {
|
|
159
178
|
const rawFields = issue.fields;
|
|
@@ -984,6 +1003,52 @@ export class JiraClient {
|
|
|
984
1003
|
const response = await this.client.issues.createIssue({ fields });
|
|
985
1004
|
return { key: response.key };
|
|
986
1005
|
}
|
|
1006
|
+
async createFilter(name, jql, description, favourite) {
|
|
1007
|
+
const result = await this.client.filters.createFilter({
|
|
1008
|
+
name,
|
|
1009
|
+
jql,
|
|
1010
|
+
description: description || '',
|
|
1011
|
+
favourite: favourite ?? false,
|
|
1012
|
+
});
|
|
1013
|
+
if (!result.id || !result.name) {
|
|
1014
|
+
throw new Error('Invalid filter response from Jira');
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
id: result.id,
|
|
1018
|
+
name: result.name,
|
|
1019
|
+
owner: result.owner?.displayName || 'Unknown',
|
|
1020
|
+
favourite: result.favourite || false,
|
|
1021
|
+
viewUrl: result.viewUrl || '',
|
|
1022
|
+
description: result.description || '',
|
|
1023
|
+
jql: result.jql || '',
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
async updateFilter(filterId, updates) {
|
|
1027
|
+
// Fetch existing filter to merge with updates (API requires name)
|
|
1028
|
+
const existing = await this.client.filters.getFilter({ id: parseInt(filterId, 10) });
|
|
1029
|
+
const result = await this.client.filters.updateFilter({
|
|
1030
|
+
id: parseInt(filterId, 10),
|
|
1031
|
+
name: updates.name || existing.name || '',
|
|
1032
|
+
jql: updates.jql ?? existing.jql,
|
|
1033
|
+
description: updates.description ?? existing.description,
|
|
1034
|
+
favourite: updates.favourite ?? existing.favourite,
|
|
1035
|
+
});
|
|
1036
|
+
if (!result.id || !result.name) {
|
|
1037
|
+
throw new Error('Invalid filter response from Jira');
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
id: result.id,
|
|
1041
|
+
name: result.name,
|
|
1042
|
+
owner: result.owner?.displayName || 'Unknown',
|
|
1043
|
+
favourite: result.favourite || false,
|
|
1044
|
+
viewUrl: result.viewUrl || '',
|
|
1045
|
+
description: result.description || '',
|
|
1046
|
+
jql: result.jql || '',
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
async deleteFilter(filterId) {
|
|
1050
|
+
await this.client.filters.deleteFilter(filterId);
|
|
1051
|
+
}
|
|
987
1052
|
async listMyFilters(expand = false) {
|
|
988
1053
|
const filters = await this.client.filters.getMyFilters();
|
|
989
1054
|
return Promise.all(filters.map(async (filter) => {
|
|
@@ -422,11 +422,9 @@ async function handleSummary(jiraClient, jql, groupBy, compute, groupLimit = DEF
|
|
|
422
422
|
const groupBudget = maxGroupsForBudget(neededImplicits.length);
|
|
423
423
|
// Effective group cap: user's preference vs API query budget (whichever is smaller)
|
|
424
424
|
const effectiveGroupCap = Math.min(groupLimit, groupBudget);
|
|
425
|
-
if (groupBy === 'project') {
|
|
425
|
+
if (groupBy === 'project' && extractProjectKeys(jql).length > 0) {
|
|
426
|
+
// Fast path: project keys are explicit in JQL — no sampling needed
|
|
426
427
|
let keys = extractProjectKeys(jql);
|
|
427
|
-
if (keys.length === 0) {
|
|
428
|
-
throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
|
|
429
|
-
}
|
|
430
428
|
const capped = keys.length > effectiveGroupCap;
|
|
431
429
|
if (capped)
|
|
432
430
|
keys = keys.slice(0, effectiveGroupCap);
|
|
@@ -479,6 +477,11 @@ async function handleSummary(jiraClient, jql, groupBy, compute, groupLimit = DEF
|
|
|
479
477
|
lines.push('');
|
|
480
478
|
lines.push(renderSummaryTable([row], compute));
|
|
481
479
|
}
|
|
480
|
+
// Workload interpretation hint — steer LLMs to decompose before reporting raw numbers
|
|
481
|
+
if (groupBy === 'assignee') {
|
|
482
|
+
lines.push('');
|
|
483
|
+
lines.push('*Interpretation tip: High open counts per person may include backlog, review, and future-planned work — not just active tasks. Before reporting workload, break down by status: `metrics: ["summary"], groupBy: "issuetype"` scoped to one assignee to distinguish active work from queued/backlog items.*');
|
|
484
|
+
}
|
|
482
485
|
return lines.join('\n');
|
|
483
486
|
}
|
|
484
487
|
/** Detect which implicit measures are referenced by compute expressions */
|
|
@@ -628,7 +631,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
628
631
|
// If only summary requested, skip issue fetching entirely
|
|
629
632
|
if (hasSummary && fetchMetrics.length === 0) {
|
|
630
633
|
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
|
|
631
|
-
const nextSteps = analysisNextSteps(jql, []);
|
|
634
|
+
const nextSteps = analysisNextSteps(jql, [], false, groupBy);
|
|
632
635
|
return {
|
|
633
636
|
content: [{
|
|
634
637
|
type: 'text',
|
|
@@ -701,7 +704,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
701
704
|
}
|
|
702
705
|
}
|
|
703
706
|
// Next steps
|
|
704
|
-
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated);
|
|
707
|
+
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy);
|
|
705
708
|
lines.push(nextSteps);
|
|
706
709
|
return {
|
|
707
710
|
content: [{
|
|
@@ -207,14 +207,62 @@ async function handleListFilters(jiraClient, args) {
|
|
|
207
207
|
],
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
|
-
async function handleCreateFilter(
|
|
211
|
-
|
|
210
|
+
async function handleCreateFilter(jiraClient, args) {
|
|
211
|
+
if (!args.name) {
|
|
212
|
+
throw new McpError(ErrorCode.InvalidParams, 'name is required for create operation');
|
|
213
|
+
}
|
|
214
|
+
if (!args.jql) {
|
|
215
|
+
throw new McpError(ErrorCode.InvalidParams, 'jql is required for create operation');
|
|
216
|
+
}
|
|
217
|
+
const filter = await jiraClient.createFilter(args.name, args.jql, args.description, args.favourite);
|
|
218
|
+
const lines = [
|
|
219
|
+
`# Filter Created: ${filter.name}`,
|
|
220
|
+
'',
|
|
221
|
+
`**ID:** ${filter.id}`,
|
|
222
|
+
`**JQL:** \`${filter.jql}\``,
|
|
223
|
+
`**Owner:** ${filter.owner}`,
|
|
224
|
+
filter.description ? `**Description:** ${filter.description}` : '',
|
|
225
|
+
`**Favourite:** ${filter.favourite ? 'Yes' : 'No'}`,
|
|
226
|
+
'',
|
|
227
|
+
`[View in Jira](${filter.viewUrl})`,
|
|
228
|
+
].filter(Boolean);
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: 'text', text: lines.join('\n') + filterNextSteps('create', filter.id) }],
|
|
231
|
+
};
|
|
212
232
|
}
|
|
213
|
-
async function handleUpdateFilter(
|
|
214
|
-
|
|
233
|
+
async function handleUpdateFilter(jiraClient, args) {
|
|
234
|
+
if (!args.filterId) {
|
|
235
|
+
throw new McpError(ErrorCode.InvalidParams, 'filterId is required for update operation');
|
|
236
|
+
}
|
|
237
|
+
const updates = {};
|
|
238
|
+
if (args.name)
|
|
239
|
+
updates.name = args.name;
|
|
240
|
+
if (args.jql)
|
|
241
|
+
updates.jql = args.jql;
|
|
242
|
+
if (args.description !== undefined)
|
|
243
|
+
updates.description = args.description;
|
|
244
|
+
if (args.favourite !== undefined)
|
|
245
|
+
updates.favourite = args.favourite;
|
|
246
|
+
const filter = await jiraClient.updateFilter(args.filterId, updates);
|
|
247
|
+
const lines = [
|
|
248
|
+
`# Filter Updated: ${filter.name}`,
|
|
249
|
+
'',
|
|
250
|
+
`**ID:** ${filter.id}`,
|
|
251
|
+
`**JQL:** \`${filter.jql}\``,
|
|
252
|
+
`**Owner:** ${filter.owner}`,
|
|
253
|
+
];
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: 'text', text: lines.join('\n') + filterNextSteps('get', filter.id) }],
|
|
256
|
+
};
|
|
215
257
|
}
|
|
216
|
-
async function handleDeleteFilter(
|
|
217
|
-
|
|
258
|
+
async function handleDeleteFilter(jiraClient, args) {
|
|
259
|
+
if (!args.filterId) {
|
|
260
|
+
throw new McpError(ErrorCode.InvalidParams, 'filterId is required for delete operation');
|
|
261
|
+
}
|
|
262
|
+
await jiraClient.deleteFilter(args.filterId);
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: 'text', text: `Filter ${args.filterId} deleted.` }],
|
|
265
|
+
};
|
|
218
266
|
}
|
|
219
267
|
async function handleExecuteFilter(jiraClient, _args) {
|
|
220
268
|
const filterId = _args.filterId;
|
|
@@ -187,7 +187,8 @@ async function handleGetIssue(jiraClient, args) {
|
|
|
187
187
|
// Get issue with requested expansions and catalog custom fields
|
|
188
188
|
const includeComments = expansionOptions.comments || false;
|
|
189
189
|
const includeAttachments = expansionOptions.attachments || false;
|
|
190
|
-
const
|
|
190
|
+
const includeHistory = expansionOptions.history || false;
|
|
191
|
+
const issue = await jiraClient.getIssue(args.issueKey, includeComments, includeAttachments, getCatalogFieldMeta(), includeHistory);
|
|
191
192
|
// Get transitions if requested
|
|
192
193
|
let transitions = undefined;
|
|
193
194
|
if (expansionOptions.transitions) {
|
package/build/index.js
CHANGED
|
@@ -95,6 +95,9 @@ class JiraServer {
|
|
|
95
95
|
const { name, arguments: args } = request.params;
|
|
96
96
|
return getPrompt(name, args);
|
|
97
97
|
});
|
|
98
|
+
// Track consecutive single-issue calls to suggest queue tool
|
|
99
|
+
const QUEUE_HINT_THRESHOLD = 3;
|
|
100
|
+
let consecutiveIssueCalls = 0;
|
|
98
101
|
// Set up tool handlers
|
|
99
102
|
this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
100
103
|
console.error('Received request:', JSON.stringify(request, null, 2));
|
|
@@ -121,6 +124,17 @@ class JiraServer {
|
|
|
121
124
|
if (!response) {
|
|
122
125
|
throw new McpError(ErrorCode.InternalError, `No response from handler for tool: ${name}`);
|
|
123
126
|
}
|
|
127
|
+
// Track consecutive manage_jira_issue calls and suggest queue tool
|
|
128
|
+
if (name === 'manage_jira_issue') {
|
|
129
|
+
consecutiveIssueCalls++;
|
|
130
|
+
if (consecutiveIssueCalls >= QUEUE_HINT_THRESHOLD && response.content?.[0]?.text) {
|
|
131
|
+
response.content[0].text += `\n\n---\n**💡 Efficiency tip:** You've made ${consecutiveIssueCalls} consecutive \`manage_jira_issue\` calls. Consider using \`queue_jira_operations\` to batch multiple issue operations into a single call — it's faster and uses less context.`;
|
|
132
|
+
consecutiveIssueCalls = 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
consecutiveIssueCalls = 0;
|
|
137
|
+
}
|
|
124
138
|
return response;
|
|
125
139
|
}
|
|
126
140
|
catch (error) {
|
|
@@ -165,6 +165,20 @@ export function renderIssue(issue, transitions) {
|
|
|
165
165
|
lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
+
// Status history (if requested via expand: ["history"])
|
|
169
|
+
if (issue.statusHistory && issue.statusHistory.length > 0) {
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push('Status History:');
|
|
172
|
+
for (const h of issue.statusHistory) {
|
|
173
|
+
lines.push(`${formatDate(h.date)}: ${h.from} → ${h.to} (by ${h.author})`);
|
|
174
|
+
}
|
|
175
|
+
// Show time in current status
|
|
176
|
+
const last = issue.statusHistory[issue.statusHistory.length - 1];
|
|
177
|
+
const daysSince = Math.floor((Date.now() - new Date(last.date).getTime()) / (1000 * 60 * 60 * 24));
|
|
178
|
+
if (daysSince > 0) {
|
|
179
|
+
lines.push(`*In "${last.to}" for ${daysSince} days*`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
168
182
|
// Available transitions
|
|
169
183
|
if (transitions && transitions.length > 0) {
|
|
170
184
|
lines.push('');
|
|
@@ -125,11 +125,21 @@ export function boardNextSteps(operation, boardId) {
|
|
|
125
125
|
}
|
|
126
126
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
127
127
|
}
|
|
128
|
-
export function analysisNextSteps(jql, issueKeys, truncated = false) {
|
|
128
|
+
export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy) {
|
|
129
129
|
const steps = [];
|
|
130
130
|
if (issueKeys.length > 0) {
|
|
131
131
|
steps.push({ description: 'Get details on a specific issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: issueKeys[0] } });
|
|
132
132
|
}
|
|
133
|
+
// Contextual cross-dimension suggestions based on groupBy
|
|
134
|
+
if (groupBy === 'assignee') {
|
|
135
|
+
steps.push({ description: 'Break down a person\'s workload by status (active vs backlog vs review)', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = "<name>"`, metrics: ['summary'], groupBy: 'issuetype' } }, { description: 'Compare workload health with computed metrics', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee', compute: ['overdue_pct = overdue / open * 100', 'stale_pct = stale / open * 100'] } });
|
|
136
|
+
}
|
|
137
|
+
else if (groupBy === 'issuetype' || groupBy === 'priority') {
|
|
138
|
+
steps.push({ description: 'See who owns these issues', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } });
|
|
139
|
+
}
|
|
140
|
+
else if (groupBy === 'project') {
|
|
141
|
+
steps.push({ description: 'Drill into a project by assignee', tool: 'analyze_jira_issues', example: { jql: `${jql} AND project = <KEY>`, metrics: ['summary'], groupBy: 'assignee' } });
|
|
142
|
+
}
|
|
133
143
|
steps.push({ description: 'Discover dimensions for cube analysis', tool: 'analyze_jira_issues', example: { jql, metrics: ['cube_setup'] } }, { description: 'Add computed columns', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'project', compute: ['bug_pct = bugs / total * 100'] } }, { description: 'Narrow the analysis with refined JQL', tool: 'analyze_jira_issues', example: { jql: `${jql} AND priority = High` } }, { description: 'View the full issue list', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql } });
|
|
134
144
|
if (truncated) {
|
|
135
145
|
steps.push({ description: 'Distribution counts above are approximate (issue cap hit). For exact breakdowns use summary + groupBy', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } }, { description: 'Or narrow JQL for precise detail metrics', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
|
package/package.json
CHANGED