@aaronsb/jira-cloud-mcp 0.5.9 → 0.5.11

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.
@@ -104,6 +104,7 @@ export class JiraClient {
104
104
  people.push({ displayName: fields.reporter.displayName, accountId: fields.reporter.accountId, role: 'reporter' });
105
105
  }
106
106
  return {
107
+ id: issue.id,
107
108
  key: issue.key,
108
109
  summary: fields?.summary,
109
110
  description: fields?.description
@@ -410,6 +411,43 @@ export class JiraClient {
410
411
  url: attachment.content || '',
411
412
  }));
412
413
  }
414
+ async getBulkChangelogs(issueKeys, fieldIds = ['status']) {
415
+ const result = new Map();
416
+ let nextPageToken;
417
+ do {
418
+ const response = await this.client.issues.getBulkChangelogs({
419
+ issueIdsOrKeys: issueKeys,
420
+ fieldIds,
421
+ maxResults: 1000,
422
+ nextPageToken,
423
+ });
424
+ for (const issueLog of response.issueChangeLogs || []) {
425
+ const issueId = issueLog.issueId;
426
+ if (!issueId)
427
+ continue;
428
+ const transitions = result.get(issueId) || [];
429
+ for (const history of issueLog.changeHistories || []) {
430
+ for (const item of history.items || []) {
431
+ if (item.field === 'status') {
432
+ transitions.push({
433
+ date: history.created || '',
434
+ from: item.fromString || '',
435
+ to: item.toString || '',
436
+ });
437
+ }
438
+ }
439
+ }
440
+ result.set(issueId, transitions);
441
+ }
442
+ nextPageToken = response.nextPageToken ?? undefined;
443
+ } while (nextPageToken);
444
+ return result;
445
+ }
446
+ async getFilter(filterId) {
447
+ return await this.client.filters.getFilter({
448
+ id: parseInt(filterId, 10),
449
+ });
450
+ }
413
451
  async getFilterIssues(filterId) {
414
452
  const filter = await this.client.filters.getFilter({
415
453
  id: parseInt(filterId, 10)
@@ -528,6 +528,14 @@ function generateAnalysisToolDocumentation(schema) {
528
528
  { description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
529
529
  ],
530
530
  },
531
+ {
532
+ title: "Flow analysis — where do issues get stuck?",
533
+ description: "Analyze status transitions, time in status, and bounce patterns:",
534
+ steps: [
535
+ { description: "Flow metrics", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["flow"] } },
536
+ { description: "Combined with summary", code: { jql: "project = AA", metrics: ["summary", "flow"], groupBy: "issuetype" } },
537
+ ],
538
+ },
531
539
  {
532
540
  title: "Data cube — discover then compute",
533
541
  description: "Two-phase analysis with computed columns:",
@@ -3,6 +3,7 @@ import { evaluateRow, extractColumnRefs, parseComputeList } from '../utils/cube-
3
3
  import { analysisNextSteps } from '../utils/next-steps.js';
4
4
  import { normalizeArgs } from '../utils/normalize-args.js';
5
5
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
6
+ // flow is opt-in only — requires extra bulk changelog API call
6
7
  const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
7
8
  const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
8
9
  const MAX_ISSUES_DEFAULT = 100;
@@ -271,6 +272,100 @@ export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
271
272
  lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
272
273
  return lines.join('\n');
273
274
  }
275
+ export async function renderFlow(jiraClient, issues) {
276
+ if (issues.length === 0)
277
+ return '## Flow\n\nNo issues to analyze.';
278
+ const issueKeys = issues.map(i => i.key);
279
+ // Build a map of issue key → issue ID for matching bulk response
280
+ const keyToId = new Map();
281
+ const idToKey = new Map();
282
+ for (const issue of issues) {
283
+ if (issue.id) {
284
+ keyToId.set(issue.key, issue.id);
285
+ idToKey.set(issue.id, issue.key);
286
+ }
287
+ }
288
+ const changelogs = await jiraClient.getBulkChangelogs(issueKeys);
289
+ // Aggregate per-status stats
290
+ const statusStats = new Map();
291
+ const issueBounceCounts = new Map(); // issueId → status → entry count
292
+ let totalTransitions = 0;
293
+ for (const [issueId, transitions] of changelogs) {
294
+ if (transitions.length === 0)
295
+ continue;
296
+ totalTransitions += transitions.length;
297
+ // Sort transitions by date
298
+ const sorted = [...transitions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
299
+ // Track entries per status for this issue
300
+ const issueStatusEntries = new Map();
301
+ for (let i = 0; i < sorted.length; i++) {
302
+ const t = sorted[i];
303
+ const toStatus = t.to;
304
+ // Count entries into the target status
305
+ issueStatusEntries.set(toStatus, (issueStatusEntries.get(toStatus) || 0) + 1);
306
+ // Calculate time spent in the target status
307
+ const enteredAt = new Date(t.date).getTime();
308
+ const exitedAt = i + 1 < sorted.length
309
+ ? new Date(sorted[i + 1].date).getTime()
310
+ : Date.now();
311
+ const daysIn = (exitedAt - enteredAt) / (1000 * 60 * 60 * 24);
312
+ const stats = statusStats.get(toStatus) || { status: toStatus, entries: 0, totalDaysIn: 0, issueCount: 0, bounces: 0 };
313
+ stats.entries++;
314
+ stats.totalDaysIn += daysIn;
315
+ statusStats.set(toStatus, stats);
316
+ }
317
+ issueBounceCounts.set(issueId, issueStatusEntries);
318
+ }
319
+ // Count distinct issues and bounces per status
320
+ const issuesPerStatus = new Map();
321
+ for (const [issueId, statusEntries] of issueBounceCounts) {
322
+ for (const [status, count] of statusEntries) {
323
+ if (!issuesPerStatus.has(status))
324
+ issuesPerStatus.set(status, new Set());
325
+ issuesPerStatus.get(status).add(issueId);
326
+ const stats = statusStats.get(status);
327
+ if (stats && count > 1) {
328
+ stats.bounces += count - 1;
329
+ }
330
+ }
331
+ }
332
+ for (const [status, issueSet] of issuesPerStatus) {
333
+ const stats = statusStats.get(status);
334
+ if (stats)
335
+ stats.issueCount = issueSet.size;
336
+ }
337
+ if (statusStats.size === 0) {
338
+ return '## Flow\n\nNo status transitions found in these issues.';
339
+ }
340
+ // Render table
341
+ const lines = ['## Flow (Status Transitions)', ''];
342
+ lines.push(`${totalTransitions} transitions across ${changelogs.size} issues`);
343
+ lines.push('');
344
+ lines.push('| Status | Entries | Avg days in | Bounce rate | Issues |');
345
+ lines.push('|--------|--------:|------------:|------------:|-------:|');
346
+ const sorted = [...statusStats.values()].sort((a, b) => b.entries - a.entries);
347
+ for (const s of sorted) {
348
+ const avgDays = s.entries > 0 ? (s.totalDaysIn / s.entries).toFixed(1) : '—';
349
+ const bounceRate = s.issueCount > 0 ? Math.round((s.bounces / s.issueCount) * 100) : 0;
350
+ lines.push(`| ${s.status} | ${s.entries} | ${avgDays} | ${bounceRate}% | ${s.issueCount} |`);
351
+ }
352
+ // Top bouncers — issues with most re-entries to any status
353
+ const bouncerScores = [];
354
+ for (const [issueId, statusEntries] of issueBounceCounts) {
355
+ const totalBounces = [...statusEntries.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
356
+ if (totalBounces > 0) {
357
+ const key = idToKey.get(issueId) || issueId;
358
+ bouncerScores.push({ key, bounces: totalBounces });
359
+ }
360
+ }
361
+ if (bouncerScores.length > 0) {
362
+ bouncerScores.sort((a, b) => b.bounces - a.bounces);
363
+ const top = bouncerScores.slice(0, 5);
364
+ lines.push('');
365
+ lines.push(`**Top bouncers:** ${top.map(b => `${b.key} (${b.bounces}×)`).join(', ')}`);
366
+ }
367
+ return lines.join('\n');
368
+ }
274
369
  /** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
275
370
  export function extractProjectKeys(jql) {
276
371
  // project in (AA, GC, GD)
@@ -592,9 +687,29 @@ async function handleCubeSetup(jiraClient, jql) {
592
687
  // ── Main Handler ───────────────────────────────────────────────────────
593
688
  export async function handleAnalysisRequest(jiraClient, request) {
594
689
  const args = normalizeArgs(request.params?.arguments || {});
595
- const jql = args.jql;
596
- if (!jql || typeof jql !== 'string' || jql.trim() === '') {
597
- throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
690
+ // Resolve JQL: filterId takes precedence over inline jql
691
+ let jql;
692
+ let filterSource;
693
+ const filterId = args.filterId;
694
+ if (filterId && typeof filterId === 'string' && filterId.trim() !== '') {
695
+ let filter;
696
+ try {
697
+ filter = await jiraClient.getFilter(filterId);
698
+ }
699
+ catch {
700
+ throw new McpError(ErrorCode.InvalidParams, `Filter ${filterId} not found or not accessible. Use manage_jira_filter with operation "list" to see available filters.`);
701
+ }
702
+ if (!filter.jql) {
703
+ throw new McpError(ErrorCode.InvalidParams, `Filter ${filterId} has no JQL query.`);
704
+ }
705
+ jql = filter.jql;
706
+ filterSource = `${filter.name || filterId} (filter ${filterId})`;
707
+ }
708
+ else {
709
+ jql = args.jql;
710
+ if (!jql || typeof jql !== 'string' || jql.trim() === '') {
711
+ throw new McpError(ErrorCode.InvalidParams, 'Either jql or filterId parameter is required.');
712
+ }
598
713
  }
599
714
  // Parse requested metrics
600
715
  const requested = (args.metrics && Array.isArray(args.metrics))
@@ -602,9 +717,12 @@ export async function handleAnalysisRequest(jiraClient, request) {
602
717
  : [];
603
718
  const hasSummary = requested.includes('summary');
604
719
  const hasCubeSetup = requested.includes('cube_setup');
720
+ const hasFlow = requested.includes('flow');
605
721
  const fetchMetrics = requested.length > 0
606
722
  ? requested.filter(m => ALL_METRICS.includes(m))
607
723
  : ALL_METRICS;
724
+ // flow needs issue fetching but isn't in ALL_METRICS (opt-in only)
725
+ const needsIssueFetch = fetchMetrics.length > 0 || hasFlow;
608
726
  // Parse groupBy
609
727
  const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
610
728
  ? args.groupBy
@@ -620,22 +738,24 @@ export async function handleAnalysisRequest(jiraClient, request) {
620
738
  // Cube setup — discover dimensions from sample, no issue fetching
621
739
  if (hasCubeSetup) {
622
740
  const cubeText = await handleCubeSetup(jiraClient, jql);
623
- const nextSteps = analysisNextSteps(jql, []);
741
+ const nextSteps = analysisNextSteps(jql, [], false, undefined, filterSource);
742
+ const banner = filterSource ? `*Using saved filter: ${filterSource}*\n\n` : '';
624
743
  return {
625
744
  content: [{
626
745
  type: 'text',
627
- text: cubeText + '\n' + nextSteps,
746
+ text: banner + cubeText + '\n' + nextSteps,
628
747
  }],
629
748
  };
630
749
  }
631
- // If only summary requested, skip issue fetching entirely
632
- if (hasSummary && fetchMetrics.length === 0) {
750
+ // If only summary requested (no flow or detail metrics), skip issue fetching entirely
751
+ if (hasSummary && !needsIssueFetch) {
633
752
  const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
634
- const nextSteps = analysisNextSteps(jql, [], false, groupBy);
753
+ const nextSteps = analysisNextSteps(jql, [], false, groupBy, filterSource);
754
+ const banner = filterSource ? `*Using saved filter: ${filterSource}*\n\n` : '';
635
755
  return {
636
756
  content: [{
637
757
  type: 'text',
638
- text: summaryText + '\n' + nextSteps,
758
+ text: banner + summaryText + '\n' + nextSteps,
639
759
  }],
640
760
  };
641
761
  }
@@ -676,13 +796,17 @@ export async function handleAnalysisRequest(jiraClient, request) {
676
796
  }
677
797
  const now = new Date();
678
798
  const lines = [];
799
+ if (filterSource) {
800
+ lines.push(`*Using saved filter: ${filterSource}*`);
801
+ lines.push('');
802
+ }
679
803
  // Summary first if requested alongside other metrics
680
804
  if (hasSummary) {
681
805
  const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
682
806
  lines.push(summaryText);
683
807
  }
684
808
  // Header for fetch-based metrics
685
- if (fetchMetrics.length > 0 && allIssues.length > 0) {
809
+ if ((fetchMetrics.length > 0 || hasFlow) && allIssues.length > 0) {
686
810
  if (hasSummary)
687
811
  lines.push('');
688
812
  lines.push(`# Detail: ${jql}`);
@@ -703,8 +827,13 @@ export async function handleAnalysisRequest(jiraClient, request) {
703
827
  lines.push(renderers[metric]());
704
828
  }
705
829
  }
830
+ // Flow is opt-in and requires async bulk changelog fetch
831
+ if (hasFlow && allIssues.length > 0) {
832
+ lines.push('');
833
+ lines.push(await renderFlow(jiraClient, allIssues));
834
+ }
706
835
  // Next steps
707
- const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy);
836
+ const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy, filterSource);
708
837
  lines.push(nextSteps);
709
838
  return {
710
839
  content: [{
@@ -10,40 +10,50 @@ const builders = {
10
10
  backlog_health({ project }) {
11
11
  return {
12
12
  description: `Backlog health check for ${project}`,
13
- messages: [msg(`Analyze backlog health for project ${project}. Use the analyze_jira_issues and manage_jira_filter tools.
13
+ messages: [msg(`Analyze backlog health for project ${project}. Use the queue_jira_operations tool to run this as a pipeline.
14
14
 
15
- Step 1 — Summary with data quality signals:
16
- {"jql":"project = ${project} AND resolution = Unresolved","metrics":["summary"],"groupBy":"priority","compute":["rot_pct = backlog_rot / open * 100","stale_pct = stale / open * 100","gap_pct = no_estimate / open * 100"]}
15
+ Use queue_jira_operations with this pipeline:
16
+ Operation 0 Save the query as a reusable filter:
17
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"${project} backlog health","jql":"project = ${project} AND resolution = Unresolved"}}
17
18
 
18
- Step 2Cycle metrics for staleness distribution and status age:
19
- {"jql":"project = ${project} AND resolution = Unresolved","metrics":["cycle"],"maxResults":100}
19
+ Operation 1Summary with data quality signals (uses $0.filterId):
20
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary"],"groupBy":"priority","compute":["rot_pct = backlog_rot / open * 100","stale_pct = stale / open * 100","gap_pct = no_estimate / open * 100"]}}
20
21
 
21
- Step 3Summarize findings:
22
+ Operation 2Flow analysis for transition patterns and bottlenecks:
23
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["flow"],"maxResults":100}}
24
+
25
+ After the pipeline completes, summarize findings:
22
26
  - What percentage of the backlog is rotting (no owner, no dates, untouched)?
23
27
  - What's stuck in the same status for 30+ days?
24
28
  - What's missing estimates or start dates?
25
29
  - Flag the worst offenders by issue key.
26
- - Recommend specific triage actions.`)],
30
+ - Recommend specific triage actions.
31
+ - The saved filter can be reused for follow-up analysis or shared with the team.`)],
27
32
  };
28
33
  },
29
34
  contributor_workload({ project }) {
30
35
  return {
31
36
  description: `Contributor workload for ${project}`,
32
- messages: [msg(`Analyze contributor workload for project ${project}. Use the analyze_jira_issues tool.
37
+ messages: [msg(`Analyze contributor workload for project ${project}. Use queue_jira_operations to run the analysis pipeline in a single call.
38
+
39
+ Use queue_jira_operations with this pipeline:
40
+ Operation 0 — Save the base query as a filter:
41
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"${project} workload","jql":"project = ${project} AND resolution = Unresolved"}}
33
42
 
34
- Step 1 — Assignee distribution with quality signals:
35
- {"jql":"project = ${project} AND resolution = Unresolved","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100","blocked_pct = blocked / open * 100"]}
43
+ Operation 1 — Assignee distribution with quality signals (uses $0.filterId):
44
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100","blocked_pct = blocked / open * 100"]}}
36
45
 
37
- Step 2 For the top 3 assignees by open issue count, run scoped detail metrics:
46
+ After the pipeline, for the top 3 assignees by open count, run scoped detail:
38
47
  {"jql":"project = ${project} AND resolution = Unresolved AND assignee = '{name}'","metrics":["cycle","schedule"]}
39
48
 
40
- This pattern keeps each detail query within the sample cap for precise results.
49
+ This keeps each detail query within the sample cap for precise results.
41
50
 
42
- Step 3 — Summarize:
51
+ Summarize:
43
52
  - Who has the most open work?
44
53
  - Who has the most stale or at-risk issues?
45
54
  - Are there load imbalances?
46
- - What needs reassignment or triage?`)],
55
+ - What needs reassignment or triage?
56
+ - The saved filter can be reused for follow-up workload checks.`)],
47
57
  };
48
58
  },
49
59
  sprint_review({ board_id }) {
@@ -52,16 +62,21 @@ Step 3 — Summarize:
52
62
  messages: [msg(`Prepare a sprint review for board ${board_id}. Use manage_jira_sprint and analyze_jira_issues tools.
53
63
 
54
64
  Step 1 — Find the active sprint:
55
- {"operation":"list","boardId":${board_id},"state":"active"}
65
+ Use manage_jira_sprint: {"operation":"list","boardId":${board_id},"state":"active"}
66
+
67
+ Step 2 — Use queue_jira_operations to run the analysis pipeline (use the sprint ID from step 1):
68
+ Operation 0 — Save the sprint query as a filter:
69
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"Sprint review board ${board_id}","jql":"sprint = {sprintId}"}}
56
70
 
57
- Step 2Analyze sprint issues (use the sprint ID from step 1):
58
- {"jql":"sprint = {sprintId}","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}
71
+ Operation 1Summary + velocity metrics:
72
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}}
59
73
 
60
74
  Step 3 — Summarize:
61
75
  - Current velocity vs planned
62
76
  - Scope changes (items added/removed mid-sprint)
63
77
  - At-risk items (overdue, blocked, stale)
64
- - Completion forecast — will the sprint goal be met?`)],
78
+ - Completion forecast — will the sprint goal be met?
79
+ - The saved filter persists for daily standups or end-of-sprint reporting.`)],
65
80
  };
66
81
  },
67
82
  narrow_analysis({ jql }) {
@@ -326,21 +326,25 @@ export const toolSchemas = {
326
326
  },
327
327
  analyze_jira_issues: {
328
328
  name: 'analyze_jira_issues',
329
- description: 'Compute project metrics over issues selected by JQL. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution) only when you need per-issue analysis; these are capped at maxResults issues. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions. Read jira://analysis/recipes for composition patterns.',
329
+ description: 'Compute project metrics over issues selected by JQL or a saved filter. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution) for per-issue analysis (capped at maxResults). Use flow for status transition patterns — how issues move through statuses, where they bounce, and how long they stay. Tip: save complex JQL as a filter with manage_jira_filter, then reuse the filterId here for repeated analysis. Read jira://analysis/recipes for composition patterns.',
330
330
  inputSchema: {
331
331
  type: 'object',
332
332
  properties: {
333
333
  jql: {
334
334
  type: 'string',
335
- description: 'JQL query selecting the issues to analyze. Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
335
+ description: 'JQL query selecting the issues to analyze. Either jql or filterId is required (filterId takes precedence). Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
336
+ },
337
+ filterId: {
338
+ type: 'string',
339
+ description: 'ID of a saved Jira filter to use as the query source. The filter\'s JQL is resolved automatically. Use this to run different analyses against a saved query without repeating the JQL. Create filters with manage_jira_filter.',
336
340
  },
337
341
  metrics: {
338
342
  type: 'array',
339
343
  items: {
340
344
  type: 'string',
341
- enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
345
+ enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'flow', 'cube_setup'],
342
346
  },
343
- description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics. For counting/breakdown questions, always prefer summary + groupBy over distribution.',
347
+ description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). flow = status transition analysis from bulk changelogs — entries per status, avg time in each, bounce rates, top bouncers. cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics (excluding flow — request flow explicitly). For counting/breakdown questions, always prefer summary + groupBy over distribution.',
344
348
  },
345
349
  groupBy: {
346
350
  type: 'string',
@@ -364,12 +368,12 @@ export const toolSchemas = {
364
368
  default: 100,
365
369
  },
366
370
  },
367
- required: ['jql'],
371
+ required: [],
368
372
  },
369
373
  },
370
374
  queue_jira_operations: {
371
375
  name: 'queue_jira_operations',
372
- description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue).',
376
+ description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue). Powerful for analysis pipelines: create a filter, then run multiple analyze_jira_issues calls against $0.filterId with different groupBy/compute — all in one call.',
373
377
  inputSchema: {
374
378
  type: 'object',
375
379
  properties: {
@@ -61,7 +61,7 @@ export function filterNextSteps(operation, filterId, jql) {
61
61
  steps.push({ description: 'Run a filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId: '<id>' } }, { description: 'Search with JQL directly', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: '<query>' } });
62
62
  break;
63
63
  case 'create':
64
- steps.push({ description: 'Execute the new filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId } });
64
+ steps.push({ description: 'Execute the new filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId } }, { description: 'Run analysis against this filter', tool: 'analyze_jira_issues', example: { filterId, metrics: ['summary'], groupBy: 'assignee' } });
65
65
  break;
66
66
  }
67
67
  return steps.length > 0 ? formatSteps(steps) : '';
@@ -125,7 +125,7 @@ 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, groupBy) {
128
+ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
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] } });
@@ -144,5 +144,9 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy) {
144
144
  if (truncated) {
145
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'] } });
146
146
  }
147
+ // Suggest saving as filter if not already using one
148
+ if (!filterSource) {
149
+ steps.push({ description: 'Save this query as a filter for reuse across analyses', tool: 'manage_jira_filter', example: { operation: 'create', name: '<descriptive name>', jql } });
150
+ }
147
151
  return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
148
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
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",