@aaronsb/jira-cloud-mcp 0.4.3 → 0.5.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.
@@ -100,6 +100,7 @@ export class JiraClient {
100
100
  assignee: fields?.assignee?.displayName || null,
101
101
  reporter: fields?.reporter?.displayName || '',
102
102
  status: fields?.status?.name || '',
103
+ statusCategory: fields?.status?.statusCategory?.key || 'unknown',
103
104
  resolution: fields?.resolution?.name || null,
104
105
  labels: fields?.labels || [],
105
106
  created: fields?.created || '',
@@ -107,8 +108,8 @@ export class JiraClient {
107
108
  resolutionDate: fields?.resolutiondate || null,
108
109
  dueDate: fields?.duedate || null,
109
110
  startDate: fields?.[this.customFields.startDate] || null,
110
- storyPoints: fields?.[this.customFields.storyPoints] || null,
111
- timeEstimate: fields?.timeestimate || null,
111
+ storyPoints: fields?.[this.customFields.storyPoints] ?? null,
112
+ timeEstimate: fields?.timeestimate ?? null,
112
113
  issueLinks: (fields?.issuelinks || []).map((link) => ({
113
114
  type: link.type?.name || '',
114
115
  outward: link.outwardIssue?.key || null,
@@ -406,6 +407,48 @@ export class JiraClient {
406
407
  comment: TextProcessor.markdownToAdf(commentBody)
407
408
  });
408
409
  }
410
+ /** Lightweight search returning only fields needed for analysis (no description, links, rendered HTML) */
411
+ async searchIssuesLean(jql, maxResults = 50, nextPageToken) {
412
+ try {
413
+ const cleanJql = jql.replace(/\\"/g, '"');
414
+ console.error(`Executing lean JQL search with query: ${cleanJql}${nextPageToken ? ' (page token)' : ''}`);
415
+ const leanFields = [
416
+ 'summary', 'issuetype', 'priority', 'assignee', 'reporter',
417
+ 'status', 'resolution', 'labels', 'created', 'updated',
418
+ 'resolutiondate', 'duedate', 'timeestimate',
419
+ this.customFields.startDate, this.customFields.storyPoints,
420
+ ];
421
+ const params = {
422
+ jql: cleanJql,
423
+ maxResults: Math.min(maxResults, 50),
424
+ fields: leanFields,
425
+ };
426
+ if (nextPageToken) {
427
+ params.nextPageToken = nextPageToken;
428
+ }
429
+ const timeoutMs = 30_000;
430
+ const searchResults = await Promise.race([
431
+ this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch(params),
432
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Lean search timed out after ${timeoutMs / 1000}s — try a narrower JQL query or smaller maxResults`)), timeoutMs)),
433
+ ]);
434
+ const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
435
+ const hasMore = !!searchResults.nextPageToken;
436
+ return {
437
+ issues,
438
+ pagination: {
439
+ startAt: 0,
440
+ maxResults,
441
+ total: hasMore ? issues.length + 1 : issues.length,
442
+ hasMore,
443
+ nextPageToken: searchResults.nextPageToken || undefined,
444
+ }
445
+ };
446
+ }
447
+ catch (error) {
448
+ console.error('Error executing lean JQL search:', error);
449
+ throw error;
450
+ }
451
+ }
409
452
  async searchIssues(jql, startAt = 0, maxResults = 25) {
410
453
  try {
411
454
  // Remove escaped quotes from JQL
@@ -15,6 +15,7 @@ const documentationGenerators = {
15
15
  manage_jira_filter: generateFilterToolDocumentation,
16
16
  manage_jira_project: generateProjectToolDocumentation,
17
17
  queue_jira_operations: generateQueueToolDocumentation,
18
+ analyze_jira_issues: generateAnalysisToolDocumentation,
18
19
  };
19
20
  export function generateToolDocumentation(toolName, schema) {
20
21
  const generator = documentationGenerators[toolName];
@@ -460,6 +461,59 @@ function generateQueueToolDocumentation(_schema) {
460
461
  related_resources: [],
461
462
  };
462
463
  }
464
+ function generateAnalysisToolDocumentation(schema) {
465
+ return {
466
+ name: "Issue Analysis",
467
+ description: schema.description,
468
+ parameters: {
469
+ jql: {
470
+ type: "string",
471
+ description: "JQL query selecting the issues to analyze.",
472
+ required: true,
473
+ },
474
+ metrics: {
475
+ type: "array of strings",
476
+ description: "Which metric groups to include. Default: all.",
477
+ values: ["points", "time", "schedule", "cycle", "distribution"],
478
+ },
479
+ maxResults: {
480
+ type: "integer",
481
+ description: "Max issues to analyze (default 100, max 500).",
482
+ },
483
+ },
484
+ metric_groups: {
485
+ points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
486
+ time: "Effort — original estimate, completed, remaining by status category",
487
+ schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
488
+ cycle: "Flow — lead time median/mean, throughput, open issue age, oldest open",
489
+ distribution: "Composition — counts by status, assignee, priority, issue type",
490
+ },
491
+ common_use_cases: [
492
+ {
493
+ title: "Sprint health check",
494
+ description: "Analyze all issues in the current sprint:",
495
+ steps: [
496
+ { description: "Full analysis", code: { jql: "sprint in openSprints()" } },
497
+ ],
498
+ },
499
+ {
500
+ title: "Schedule risk for a release",
501
+ description: "Check overdue and upcoming deadlines:",
502
+ steps: [
503
+ { description: "Schedule only", code: { jql: "project = AA AND fixVersion = 2.0", metrics: ["schedule"] } },
504
+ ],
505
+ },
506
+ {
507
+ title: "Workload balance",
508
+ description: "See how work is distributed across team members:",
509
+ steps: [
510
+ { description: "Distribution only", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["distribution"] } },
511
+ ],
512
+ },
513
+ ],
514
+ related_resources: [],
515
+ };
516
+ }
463
517
  function generateGenericToolDocumentation(toolName, schema) {
464
518
  const operations = {};
465
519
  if (schema.inputSchema?.properties?.operation?.enum) {
@@ -0,0 +1,300 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { analysisNextSteps } from '../utils/next-steps.js';
3
+ import { normalizeArgs } from '../utils/normalize-args.js';
4
+ const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
5
+ const MAX_ISSUES = 500;
6
+ // ── Helpers ────────────────────────────────────────────────────────────
7
+ function bucketStatus(category) {
8
+ switch (category) {
9
+ case 'new': return 'To Do';
10
+ case 'indeterminate': return 'In Progress';
11
+ case 'done': return 'Done';
12
+ default: return 'To Do';
13
+ }
14
+ }
15
+ /** Parse a date string to a Date without timezone shift for date-only values */
16
+ function parseDate(dateStr) {
17
+ const dateOnly = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
18
+ if (dateOnly) {
19
+ const [, y, m, d] = dateOnly;
20
+ return new Date(Number(y), Number(m) - 1, Number(d));
21
+ }
22
+ return new Date(dateStr);
23
+ }
24
+ function formatDateShort(date) {
25
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
26
+ }
27
+ function daysBetween(a, b) {
28
+ return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
29
+ }
30
+ function formatDuration(seconds) {
31
+ const hours = Math.floor(seconds / 3600);
32
+ const days = Math.floor(hours / 8); // 8-hour work day
33
+ if (days > 0) {
34
+ const remainingHours = hours % 8;
35
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
36
+ }
37
+ const minutes = Math.floor((seconds % 3600) / 60);
38
+ if (hours > 0)
39
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
40
+ return `${minutes}m`;
41
+ }
42
+ function countBy(items, keyFn) {
43
+ const counts = new Map();
44
+ for (const item of items) {
45
+ const key = keyFn(item);
46
+ counts.set(key, (counts.get(key) || 0) + 1);
47
+ }
48
+ return counts;
49
+ }
50
+ function sumBy(items, valueFn) {
51
+ return items.reduce((sum, item) => sum + (valueFn(item) || 0), 0);
52
+ }
53
+ function mapToString(map, separator = ' | ') {
54
+ return [...map.entries()]
55
+ .sort((a, b) => b[1] - a[1])
56
+ .map(([k, v]) => `${k}: ${v}`)
57
+ .join(separator);
58
+ }
59
+ function median(values) {
60
+ if (values.length === 0)
61
+ return 0;
62
+ const sorted = [...values].sort((a, b) => a - b);
63
+ const mid = Math.floor(sorted.length / 2);
64
+ return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
65
+ }
66
+ function mean(values) {
67
+ if (values.length === 0)
68
+ return 0;
69
+ return values.reduce((s, v) => s + v, 0) / values.length;
70
+ }
71
+ // ── Metric Renderers (exported for testing) ────────────────────────────
72
+ export function renderPoints(issues) {
73
+ const estimated = issues.filter(i => i.storyPoints != null);
74
+ const unestimated = issues.length - estimated.length;
75
+ const byBucket = new Map();
76
+ for (const issue of issues) {
77
+ const bucket = bucketStatus(issue.statusCategory);
78
+ byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.storyPoints ?? 0));
79
+ }
80
+ const pv = sumBy(issues, i => i.storyPoints);
81
+ const ev = byBucket.get('Done') ?? 0;
82
+ const remaining = pv - ev;
83
+ const spi = pv > 0 ? (ev / pv) : null;
84
+ const lines = ['## Points (Earned Value)', ''];
85
+ lines.push('| Metric | Value |');
86
+ lines.push('|--------|-------|');
87
+ lines.push(`| Planned Value (PV) | ${pv} pts |`);
88
+ lines.push(`| Earned Value (EV) | ${ev} pts |`);
89
+ lines.push(`| Remaining | ${remaining} pts |`);
90
+ lines.push(`| SPI | ${spi !== null ? spi.toFixed(2) : 'N/A (no estimates)'} |`);
91
+ if (unestimated > 0) {
92
+ lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
93
+ }
94
+ lines.push('');
95
+ const bucketStr = ['To Do', 'In Progress', 'Done']
96
+ .map(b => `${b}: ${byBucket.get(b) ?? 0} pts`)
97
+ .join(' | ');
98
+ lines.push(`**By status:** ${bucketStr}`);
99
+ return lines.join('\n');
100
+ }
101
+ export function renderTime(issues) {
102
+ const estimated = issues.filter(i => i.timeEstimate != null);
103
+ const unestimated = issues.length - estimated.length;
104
+ const total = sumBy(issues, i => i.timeEstimate);
105
+ const byBucket = new Map();
106
+ for (const issue of issues) {
107
+ const bucket = bucketStatus(issue.statusCategory);
108
+ byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.timeEstimate ?? 0));
109
+ }
110
+ const done = byBucket.get('Done') ?? 0;
111
+ const remaining = total - done;
112
+ const lines = ['## Time (Effort)', ''];
113
+ lines.push('| Metric | Value |');
114
+ lines.push('|--------|-------|');
115
+ lines.push(`| Original Estimate | ${formatDuration(total)} |`);
116
+ lines.push(`| Completed | ${formatDuration(done)} |`);
117
+ lines.push(`| Remaining | ${formatDuration(remaining)} |`);
118
+ if (unestimated > 0) {
119
+ lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
120
+ }
121
+ return lines.join('\n');
122
+ }
123
+ export function renderSchedule(issues, now) {
124
+ const lines = ['## Schedule', ''];
125
+ // Date range
126
+ const startDates = issues.filter(i => i.startDate).map(i => parseDate(i.startDate));
127
+ const dueDates = issues.filter(i => i.dueDate).map(i => parseDate(i.dueDate));
128
+ const allDates = [...startDates, ...dueDates];
129
+ if (allDates.length > 0) {
130
+ const earliest = new Date(Math.min(...allDates.map(d => d.getTime())));
131
+ const latest = new Date(Math.max(...allDates.map(d => d.getTime())));
132
+ lines.push(`**Window:** ${formatDateShort(earliest)} - ${formatDateShort(latest)}`);
133
+ }
134
+ // Overdue
135
+ const overdue = issues.filter(i => i.dueDate && !i.resolutionDate && parseDate(i.dueDate) < now);
136
+ if (overdue.length > 0) {
137
+ const totalSlip = overdue.reduce((sum, i) => sum + daysBetween(parseDate(i.dueDate), now), 0);
138
+ const keys = overdue.slice(0, 5).map(i => i.key).join(', ');
139
+ const more = overdue.length > 5 ? ` +${overdue.length - 5} more` : '';
140
+ lines.push(`**Overdue:** ${overdue.length} issue${overdue.length !== 1 ? 's' : ''}, ${totalSlip} days total slip (${keys}${more})`);
141
+ }
142
+ else {
143
+ lines.push('**Overdue:** none');
144
+ }
145
+ // Due soon
146
+ for (const window of [7, 14, 30]) {
147
+ const cutoff = new Date(now.getTime() + window * 24 * 60 * 60 * 1000);
148
+ const dueSoon = issues.filter(i => i.dueDate && !i.resolutionDate &&
149
+ parseDate(i.dueDate) >= now && parseDate(i.dueDate) <= cutoff);
150
+ if (dueSoon.length > 0) {
151
+ lines.push(`**Due next ${window} days:** ${dueSoon.length} issue${dueSoon.length !== 1 ? 's' : ''}`);
152
+ }
153
+ }
154
+ // Concentration risk
155
+ const dueDateCounts = countBy(issues.filter(i => i.dueDate && !i.resolutionDate), i => i.dueDate);
156
+ const concentrated = [...dueDateCounts.entries()]
157
+ .filter(([, count]) => count >= 3)
158
+ .sort((a, b) => b[1] - a[1]);
159
+ if (concentrated.length > 0) {
160
+ const top = concentrated.slice(0, 3)
161
+ .map(([date, count]) => `${formatDateShort(parseDate(date))} has ${count} issues`)
162
+ .join('; ');
163
+ lines.push(`**Concentration:** ${top}`);
164
+ }
165
+ // No due date
166
+ const noDueDate = issues.filter(i => !i.dueDate && !i.resolutionDate);
167
+ if (noDueDate.length > 0) {
168
+ lines.push(`**No due date:** ${noDueDate.length} issue${noDueDate.length !== 1 ? 's' : ''}`);
169
+ }
170
+ return lines.join('\n');
171
+ }
172
+ export function renderCycle(issues, now) {
173
+ const lines = ['## Cycle (Flow Metrics)', ''];
174
+ // Lead time for resolved issues
175
+ const resolved = issues.filter(i => i.resolutionDate && i.created);
176
+ if (resolved.length > 0) {
177
+ const leadTimes = resolved.map(i => daysBetween(parseDate(i.created), parseDate(i.resolutionDate)));
178
+ const med = median(leadTimes);
179
+ const avg = mean(leadTimes);
180
+ lines.push(`**Lead time (resolved):** median ${med.toFixed(1)} days, mean ${avg.toFixed(1)} days (${resolved.length} issues)`);
181
+ // Throughput
182
+ const createdDates = resolved.map(i => parseDate(i.resolutionDate).getTime());
183
+ const earliest = Math.min(...createdDates);
184
+ const latest = Math.max(...createdDates);
185
+ const weeks = Math.max(1, (latest - earliest) / (7 * 24 * 60 * 60 * 1000));
186
+ const throughput = resolved.length / weeks;
187
+ lines.push(`**Throughput:** ${throughput.toFixed(1)} issues/week`);
188
+ }
189
+ else {
190
+ lines.push('**Lead time:** no resolved issues in set');
191
+ }
192
+ // Age of open issues
193
+ const open = issues.filter(i => !i.resolutionDate && i.created);
194
+ if (open.length > 0) {
195
+ const ages = open.map(i => daysBetween(parseDate(i.created), now));
196
+ const avgAge = mean(ages);
197
+ lines.push(`**Open issue age:** mean ${avgAge.toFixed(1)} days (${open.length} issues)`);
198
+ // Oldest open
199
+ const oldest = open
200
+ .map(i => ({ key: i.key, age: daysBetween(parseDate(i.created), now) }))
201
+ .sort((a, b) => b.age - a.age)
202
+ .slice(0, 5);
203
+ const oldestStr = oldest.map(o => `${o.key} (${o.age}d)`).join(', ');
204
+ lines.push(`**Oldest open:** ${oldestStr}`);
205
+ }
206
+ return lines.join('\n');
207
+ }
208
+ export function renderDistribution(issues) {
209
+ const lines = ['## Distribution', ''];
210
+ const byStatus = countBy(issues, i => i.status);
211
+ lines.push(`**By status:** ${mapToString(byStatus)}`);
212
+ const byAssignee = countBy(issues, i => i.assignee || 'Unassigned');
213
+ lines.push(`**By assignee:** ${mapToString(byAssignee)}`);
214
+ const byPriority = countBy(issues, i => i.priority || 'None');
215
+ lines.push(`**By priority:** ${mapToString(byPriority)}`);
216
+ const byType = countBy(issues, i => i.issueType || 'Unknown');
217
+ lines.push(`**By type:** ${mapToString(byType)}`);
218
+ return lines.join('\n');
219
+ }
220
+ // ── Main Handler ───────────────────────────────────────────────────────
221
+ export async function handleAnalysisRequest(jiraClient, request) {
222
+ const args = normalizeArgs(request.params?.arguments || {});
223
+ const jql = args.jql;
224
+ if (!jql || typeof jql !== 'string' || jql.trim() === '') {
225
+ throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
226
+ }
227
+ const DEFAULT_MAX = 100;
228
+ const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
229
+ // Parse requested metrics
230
+ let metrics = ALL_METRICS;
231
+ if (args.metrics && Array.isArray(args.metrics)) {
232
+ const requested = args.metrics;
233
+ const valid = requested.filter(m => ALL_METRICS.includes(m));
234
+ if (valid.length > 0)
235
+ metrics = valid;
236
+ }
237
+ // Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
238
+ const allIssues = [];
239
+ const seen = new Set();
240
+ let nextPageToken;
241
+ let truncated = false;
242
+ const maxPages = Math.ceil(maxResults / 50) + 1;
243
+ let pageCount = 0;
244
+ while (allIssues.length < maxResults) {
245
+ if (++pageCount > maxPages)
246
+ break;
247
+ const remaining = maxResults - allIssues.length;
248
+ const result = await jiraClient.searchIssuesLean(jql, Math.min(50, remaining), nextPageToken);
249
+ for (const issue of result.issues) {
250
+ if (!seen.has(issue.key)) {
251
+ seen.add(issue.key);
252
+ allIssues.push(issue);
253
+ }
254
+ }
255
+ if (!result.pagination.hasMore || result.issues.length === 0)
256
+ break;
257
+ nextPageToken = result.pagination.nextPageToken;
258
+ if (allIssues.length >= maxResults) {
259
+ truncated = result.pagination.hasMore;
260
+ break;
261
+ }
262
+ }
263
+ if (allIssues.length === 0) {
264
+ return {
265
+ content: [{
266
+ type: 'text',
267
+ text: `# Analysis\n\n**JQL:** \`${jql}\`\n\nNo issues matched this query.`,
268
+ }],
269
+ };
270
+ }
271
+ const now = new Date();
272
+ const lines = [];
273
+ // Header
274
+ lines.push(`# Analysis: ${jql}`);
275
+ lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
276
+ if (truncated) {
277
+ lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
278
+ }
279
+ // Render requested metrics
280
+ const renderers = {
281
+ points: () => renderPoints(allIssues),
282
+ time: () => renderTime(allIssues),
283
+ schedule: () => renderSchedule(allIssues, now),
284
+ cycle: () => renderCycle(allIssues, now),
285
+ distribution: () => renderDistribution(allIssues),
286
+ };
287
+ for (const metric of metrics) {
288
+ lines.push('');
289
+ lines.push(renderers[metric]());
290
+ }
291
+ // Next steps
292
+ const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
293
+ lines.push(nextSteps);
294
+ return {
295
+ content: [{
296
+ type: 'text',
297
+ text: lines.join('\n'),
298
+ }],
299
+ };
300
+ }
package/build/index.js CHANGED
@@ -5,6 +5,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
5
  import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { fieldDiscovery } from './client/field-discovery.js';
7
7
  import { JiraClient } from './client/jira-client.js';
8
+ import { handleAnalysisRequest } from './handlers/analysis-handler.js';
8
9
  import { handleBoardRequest } from './handlers/board-handlers.js';
9
10
  import { handleFilterRequest } from './handlers/filter-handlers.js';
10
11
  import { handleIssueRequest } from './handlers/issue-handlers.js';
@@ -91,6 +92,7 @@ class JiraServer {
91
92
  manage_jira_board: handleBoardRequest,
92
93
  manage_jira_sprint: handleSprintRequest,
93
94
  manage_jira_filter: handleFilterRequest,
95
+ analyze_jira_issues: handleAnalysisRequest,
94
96
  };
95
97
  const handlers = {
96
98
  ...toolHandlers,
@@ -324,6 +324,33 @@ export const toolSchemas = {
324
324
  required: ['operation'],
325
325
  },
326
326
  },
327
+ analyze_jira_issues: {
328
+ name: 'analyze_jira_issues',
329
+ description: 'Compute project metrics over a set of issues selected by JQL. Returns deterministic calculations: earned value (story points), effort tracking, schedule risk, flow/cycle metrics, and distribution breakdowns. Use this when asked about totals, progress, overdue items, workload balance, or any quantitative question about issues.',
330
+ inputSchema: {
331
+ type: 'object',
332
+ properties: {
333
+ jql: {
334
+ type: 'string',
335
+ description: 'JQL query selecting the issues to analyze. Examples: "sprint in openSprints()", "project = AA AND fixVersion = 2.0", "assignee = currentUser() AND resolution = Unresolved".',
336
+ },
337
+ metrics: {
338
+ type: 'array',
339
+ items: {
340
+ type: 'string',
341
+ enum: ['points', 'time', 'schedule', 'cycle', 'distribution'],
342
+ },
343
+ description: 'Which metric groups to include. Default: all. points = earned value/SPI, time = effort estimates, schedule = due dates/overdue/risk, cycle = lead time/throughput/age, distribution = counts by status/assignee/priority/type.',
344
+ },
345
+ maxResults: {
346
+ type: 'integer',
347
+ description: 'Max issues to analyze (default 100, max 500).',
348
+ default: 100,
349
+ },
350
+ },
351
+ required: ['jql'],
352
+ },
353
+ },
327
354
  queue_jira_operations: {
328
355
  name: 'queue_jira_operations',
329
356
  description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue).',
@@ -337,7 +364,7 @@ export const toolSchemas = {
337
364
  properties: {
338
365
  tool: {
339
366
  type: 'string',
340
- enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board'],
367
+ enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues'],
341
368
  description: 'Which tool to call.',
342
369
  },
343
370
  args: {
@@ -125,3 +125,11 @@ export function boardNextSteps(operation, boardId) {
125
125
  }
126
126
  return steps.length > 0 ? formatSteps(steps) : '';
127
127
  }
128
+ export function analysisNextSteps(jql, issueKeys) {
129
+ const steps = [];
130
+ if (issueKeys.length > 0) {
131
+ steps.push({ description: 'Get details on a specific issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: issueKeys[0] } });
132
+ }
133
+ steps.push({ 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
+ return formatSteps(steps);
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",