@aaronsb/jira-cloud-mcp 0.5.2 → 0.5.4

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
@@ -40,26 +40,31 @@ Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/m
40
40
 
41
41
  | Tool | Description |
42
42
  |------|-------------|
43
- | `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, or link Jira issues |
43
+ | `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, link, or traverse hierarchy of issues |
44
44
  | `manage_jira_filter` | Search for issues using JQL queries, or manage saved filters |
45
- | `manage_jira_project` | List projects or get project details including status counts |
45
+ | `manage_jira_project` | List projects or get project configuration and metadata |
46
46
  | `manage_jira_board` | List boards or get board details and configuration |
47
47
  | `manage_jira_sprint` | Manage sprints: create, start, close, and assign issues to sprints |
48
- | `queue_jira_operations` | Execute multiple operations in one call with result references and error strategies |
48
+ | `queue_jira_operations` | Batch multiple operations with result references (`$0.key`) and error strategies |
49
+ | `analyze_jira_issues` | Compute metrics, exact counts, and data cube analysis over issues selected by JQL |
49
50
 
50
- Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array). Detailed documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
51
+ Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array, and `analyze_jira_issues` which takes `jql` + `metrics`). Per-tool documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
52
+
53
+ See [docs/tools.md](docs/tools.md) for detailed tool descriptions, workspace patterns, and design principles.
51
54
 
52
55
  ## MCP Resources
53
56
 
54
57
  | Resource | Description |
55
58
  |----------|-------------|
56
59
  | `jira://instance/summary` | Instance-level statistics |
60
+ | `jira://projects/distribution` | Project distribution overview |
57
61
  | `jira://projects/{key}/overview` | Project overview with status counts |
58
62
  | `jira://boards/{id}/overview` | Board overview with sprint info |
59
63
  | `jira://issue-link-types` | Available issue link types |
60
64
  | `jira://custom-fields` | Custom field catalog (auto-discovered at startup) |
61
65
  | `jira://custom-fields/{project}/{issueType}` | Context-specific custom fields |
62
- | `jira://tools/{name}/documentation` | Tool documentation |
66
+ | `jira://analysis/recipes` | Analysis query patterns and compute DSL reference |
67
+ | `jira://tools/{name}/documentation` | Per-tool documentation |
63
68
 
64
69
  ## License
65
70
 
@@ -8,6 +8,10 @@ export class JiraClient {
8
8
  get v3Client() {
9
9
  return this.client;
10
10
  }
11
+ /** Expose custom field IDs for JQL construction outside the client */
12
+ get customFieldIds() {
13
+ return this.customFields;
14
+ }
11
15
  constructor(config) {
12
16
  const clientConfig = {
13
17
  host: config.host,
@@ -80,6 +84,7 @@ export class JiraClient {
80
84
  'created',
81
85
  'updated',
82
86
  'resolutiondate',
87
+ 'statuscategorychangedate',
83
88
  'duedate',
84
89
  this.customFields.startDate,
85
90
  this.customFields.storyPoints,
@@ -106,6 +111,7 @@ export class JiraClient {
106
111
  created: fields?.created || '',
107
112
  updated: fields?.updated || '',
108
113
  resolutionDate: fields?.resolutiondate || null,
114
+ statusCategoryChanged: fields?.statuscategorychangedate ?? fields?.statuscategorychangeddate ?? null,
109
115
  dueDate: fields?.duedate || null,
110
116
  startDate: fields?.[this.customFields.startDate] || null,
111
117
  storyPoints: fields?.[this.customFields.storyPoints] ?? null,
@@ -427,7 +433,7 @@ export class JiraClient {
427
433
  const leanFields = [
428
434
  'summary', 'issuetype', 'priority', 'assignee', 'reporter',
429
435
  'status', 'resolution', 'labels', 'created', 'updated',
430
- 'resolutiondate', 'duedate', 'timeestimate',
436
+ 'resolutiondate', 'statuscategorychangedate', 'duedate', 'timeestimate',
431
437
  this.customFields.startDate, this.customFields.storyPoints,
432
438
  ];
433
439
  const params = {
@@ -483,7 +483,7 @@ function generateAnalysisToolDocumentation(schema) {
483
483
  },
484
484
  compute: {
485
485
  type: "array of strings",
486
- description: "Computed columns for summary tables. Each: 'name = expr'. Arithmetic, comparisons, column refs. Implicit measures: bugs, unassigned, no_due_date, blocked.",
486
+ description: "Computed columns for summary tables. Each: 'name = expr'. Arithmetic, comparisons, column refs. Implicit measures: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale, stale_status, backlog_rot.",
487
487
  },
488
488
  maxResults: {
489
489
  type: "integer",
@@ -210,6 +210,48 @@ export function renderCycle(issues, now) {
210
210
  .slice(0, 5);
211
211
  const oldestStr = oldest.map(o => `${o.key} (${o.age}d)`).join(', ');
212
212
  lines.push(`**Oldest open:** ${oldestStr}`);
213
+ // Staleness — how long since last update
214
+ const staleness = open.map(i => ({
215
+ key: i.key,
216
+ days: daysBetween(parseDate(i.updated), now),
217
+ }));
218
+ const buckets = { fresh: 0, aging: 0, stale: 0, abandoned: 0 };
219
+ for (const s of staleness) {
220
+ if (s.days < 7)
221
+ buckets.fresh++;
222
+ else if (s.days < 30)
223
+ buckets.aging++;
224
+ else if (s.days < 90)
225
+ buckets.stale++;
226
+ else
227
+ buckets.abandoned++;
228
+ }
229
+ lines.push(`**Staleness:** <7d: ${buckets.fresh} | 7-30d: ${buckets.aging} | 30-90d: ${buckets.stale} | 90d+: ${buckets.abandoned}`);
230
+ // Most stale open issues
231
+ const mostStale = staleness
232
+ .sort((a, b) => b.days - a.days)
233
+ .slice(0, 5);
234
+ if (mostStale.length > 0 && mostStale[0].days >= 30) {
235
+ const staleStr = mostStale.map(s => `${s.key} (${s.days}d)`).join(', ');
236
+ lines.push(`**Most stale:** ${staleStr}`);
237
+ }
238
+ // Status age — how long in current status
239
+ const withStatusAge = open.filter(i => i.statusCategoryChanged);
240
+ if (withStatusAge.length > 0) {
241
+ const statusAges = withStatusAge.map(i => daysBetween(parseDate(i.statusCategoryChanged), now));
242
+ const med = median(statusAges);
243
+ const avg = mean(statusAges);
244
+ lines.push(`**Status age:** median ${med.toFixed(1)} days, mean ${avg.toFixed(1)} days in current status (${withStatusAge.length} issues)`);
245
+ const stuck = withStatusAge
246
+ .map(i => ({ key: i.key, status: i.status, days: daysBetween(parseDate(i.statusCategoryChanged), now) }))
247
+ .filter(s => s.days >= 30)
248
+ .sort((a, b) => b.days - a.days)
249
+ .slice(0, 5);
250
+ if (stuck.length > 0) {
251
+ const stuckStr = stuck.map(s => `${s.key} ${s.status} (${s.days}d)`).join(', ');
252
+ lines.push(`**Stuck:** ${stuckStr}`);
253
+ }
254
+ }
213
255
  }
214
256
  return lines.join('\n');
215
257
  }
@@ -248,17 +290,39 @@ export function removeProjectClause(jql) {
248
290
  .replace(/\s*AND\s*$/i, '')
249
291
  .trim();
250
292
  }
293
+ /** Run async tasks in batches to avoid API rate limiting */
294
+ async function batchParallel(tasks, batchSize) {
295
+ const results = [];
296
+ for (let i = 0; i < tasks.length; i += batchSize) {
297
+ const batch = tasks.slice(i, i + batchSize);
298
+ results.push(...await Promise.all(batch.map(fn => fn())));
299
+ }
300
+ return results;
301
+ }
302
+ const ROW_BATCH_SIZE = 3; // ~18-33 concurrent count queries per batch
251
303
  /** Build a scoped JQL by adding a condition to the base query */
252
304
  function scopeJql(baseJql, condition) {
253
305
  return `(${baseJql}) AND ${condition}`;
254
306
  }
255
- /** Implicit measures — lazily resolved if referenced in compute expressions */
256
- const IMPLICIT_MEASURES = {
257
- bugs: 'issuetype = Bug AND resolution = Unresolved',
258
- unassigned: 'assignee is EMPTY AND resolution = Unresolved',
259
- no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
260
- blocked: 'status = Blocked',
261
- };
307
+ /** Implicit measures — lazily resolved if referenced in compute expressions.
308
+ * Built dynamically because some use custom field IDs. */
309
+ function buildImplicitMeasures(customFieldIds) {
310
+ const measures = {
311
+ bugs: 'issuetype = Bug AND resolution = Unresolved',
312
+ unassigned: 'assignee is EMPTY AND resolution = Unresolved',
313
+ no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
314
+ blocked: 'status = Blocked',
315
+ no_labels: 'labels is EMPTY AND resolution = Unresolved',
316
+ stale: 'resolution = Unresolved AND updated <= -60d',
317
+ stale_status: 'resolution = Unresolved AND statusCategoryChangedDate <= -30d',
318
+ backlog_rot: 'resolution = Unresolved AND dueDate is EMPTY AND assignee is EMPTY AND updated <= -60d',
319
+ };
320
+ if (customFieldIds) {
321
+ measures.no_estimate = `${customFieldIds.storyPoints} is EMPTY AND resolution = Unresolved`;
322
+ measures.no_start_date = `${customFieldIds.startDate} is EMPTY AND resolution = Unresolved`;
323
+ }
324
+ return measures;
325
+ }
262
326
  /** Map dimension values to JQL clauses for groupBy scoping */
263
327
  export function groupByJqlClause(dimension, values) {
264
328
  switch (dimension) {
@@ -272,7 +336,7 @@ export function groupByJqlClause(dimension, values) {
272
336
  return values.map(v => `issuetype = "${v}"`);
273
337
  }
274
338
  }
275
- async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
339
+ async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames, implicitMeasureDefs) {
276
340
  const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
277
341
  jiraClient.countIssues(baseJql),
278
342
  jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
@@ -282,8 +346,8 @@ async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
282
346
  jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
283
347
  ]);
284
348
  let implicitMeasures;
285
- if (implicitMeasureNames && implicitMeasureNames.length > 0) {
286
- const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, IMPLICIT_MEASURES[name]))));
349
+ if (implicitMeasureNames && implicitMeasureNames.length > 0 && implicitMeasureDefs) {
350
+ const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, implicitMeasureDefs[name]))));
287
351
  implicitMeasures = {};
288
352
  for (let i = 0; i < implicitMeasureNames.length; i++) {
289
353
  implicitMeasures[implicitMeasureNames[i]] = counts[i];
@@ -348,8 +412,9 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
348
412
  const lines = [];
349
413
  lines.push(`# Summary: ${jql}`);
350
414
  lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
351
- // Detect implicit measures needed by compute expressions
352
- const neededImplicits = compute ? detectImplicitMeasures(compute) : [];
415
+ // Build implicit measures with custom field IDs for JQL construction
416
+ const implicitDefs = buildImplicitMeasures(jiraClient.customFieldIds);
417
+ const neededImplicits = compute ? detectImplicitMeasures(compute, implicitDefs) : [];
353
418
  const groupBudget = maxGroupsForBudget(neededImplicits.length);
354
419
  if (groupBy === 'project') {
355
420
  let keys = extractProjectKeys(jql);
@@ -360,7 +425,7 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
360
425
  if (capped)
361
426
  keys = keys.slice(0, groupBudget);
362
427
  const remaining = removeProjectClause(jql);
363
- const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits)));
428
+ const rows = await batchParallel(keys.map(k => () => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
364
429
  rows.sort((a, b) => b.unresolved - a.unresolved);
365
430
  lines.push('');
366
431
  lines.push(renderSummaryTable(rows, compute));
@@ -382,7 +447,7 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
382
447
  // Cap groups to query budget
383
448
  const cappedValues = dim.values.slice(0, groupBudget);
384
449
  const jqlClause = groupByJqlClause(groupBy, cappedValues);
385
- const rows = await Promise.all(cappedValues.map((value, idx) => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits)));
450
+ const rows = await batchParallel(cappedValues.map((value, idx) => () => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
386
451
  rows.sort((a, b) => b.unresolved - a.unresolved);
387
452
  lines.push('');
388
453
  lines.push(renderSummaryTable(rows, compute));
@@ -395,16 +460,16 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
395
460
  }
396
461
  else {
397
462
  // No groupBy — single row for the whole JQL
398
- const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits);
463
+ const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits, implicitDefs);
399
464
  lines.push('');
400
465
  lines.push(renderSummaryTable([row], compute));
401
466
  }
402
467
  return lines.join('\n');
403
468
  }
404
469
  /** Detect which implicit measures are referenced by compute expressions */
405
- function detectImplicitMeasures(compute) {
470
+ function detectImplicitMeasures(compute, implicitMeasureDefs) {
406
471
  const refs = extractColumnRefs(compute);
407
- return Object.keys(IMPLICIT_MEASURES).filter(name => refs.has(name));
472
+ return Object.keys(implicitMeasureDefs).filter(name => refs.has(name));
408
473
  }
409
474
  /** Extract distinct dimension values from sampled issues */
410
475
  export function extractDimensions(issues) {
@@ -451,7 +516,7 @@ export function renderCubeSetup(jql, sampleSize, dimensions) {
451
516
  lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
452
517
  lines.push('');
453
518
  lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
454
- lines.push('- bugs, unassigned, no_due_date, blocked');
519
+ lines.push('- bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale, stale_status, backlog_rot');
455
520
  // Suggested cubes with cost estimates
456
521
  lines.push('');
457
522
  lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
@@ -619,7 +684,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
619
684
  }
620
685
  }
621
686
  // Next steps
622
- const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
687
+ const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated);
623
688
  lines.push(nextSteps);
624
689
  return {
625
690
  content: [{
@@ -371,103 +371,102 @@ async function getContextCustomFields(jiraClient, projectKey, issueType) {
371
371
  function getAnalysisRecipes() {
372
372
  const markdown = `# Analysis Query Recipes
373
373
 
374
- ## Two Layers
374
+ ## Foundation Rules
375
375
 
376
- Stack these for magnitude + detail:
376
+ - **Always start with cube_setup** on unfamiliar JQL before committing to a query. It tells you what dimensions exist, how many distinct values each has, and estimates query cost. Think of it as DESCRIBE TABLE before a SQL query.
377
+ - **\`metrics: ["summary"] + groupBy: "project"\` is your default opening move.** It's exact (no sampling cap), fast, and gives you a cross-project comparison in one call. Use everything else to drill down after.
378
+ - **The \`distribution\` metric is rich but capped at 500 issues.** Only use it scoped to a single project, never cross-project. Mixing it into a broad query will silently undersample.
379
+ - **\`manage_jira_project\` issue counts cap at 100** — never use it for actual counts, only for metadata. \`analyze_jira_issues\` with \`metrics: ["summary"]\` is always more accurate.
377
380
 
378
- 1. **Count layer** — \`analyze_jira_issues\` with \`metrics: ["summary"]\` + \`groupBy: "project"\` → exact counts, no cap
379
- 2. **Detail layer** — \`analyze_jira_issues\` with other metrics, or \`manage_jira_filter\` execute_jql → individual issues
381
+ ## Core Recipes
380
382
 
381
- ## Recipes
382
-
383
- ### Project Health Snapshot
383
+ ### Org-wide Health Snapshot
384
384
  **Question:** Which projects are busiest / most at-risk?
385
385
  \`\`\`json
386
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
386
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "clearing = resolved_7d > created_7d", "planning_gap = no_due_date / open * 100"] }
387
387
  \`\`\`
388
+ One query, full picture. The \`clearing\` boolean immediately flags which projects are accumulating debt.
388
389
 
389
390
  ### Net Flow / Velocity
390
391
  **Question:** Are we resolving faster than creating?
391
- Run two summary queries and subtract:
392
- - \`created >= -7d\` with \`groupBy: "project"\` → created this week
393
- - \`resolved >= -7d\` with \`groupBy: "project"\` → resolved this week
394
- Net = created - resolved. Positive = growing backlog. Negative = clearing.
395
-
396
- ### Bug Ratio
397
- **Question:** How much open work is bugs vs planned?
398
392
  \`\`\`json
399
- { "jql": "issuetype = Bug AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
393
+ { "jql": "project in (...) AND (created >= -7d OR resolved >= -7d)", "metrics": ["summary"], "groupBy": "project", "compute": ["net_flow = resolved_7d - created_7d", "clearing = resolved_7d > created_7d"] }
400
394
  \`\`\`
395
+ Positive net_flow = clearing, negative = accumulating. The OR condition in JQL ensures you capture both sides of the ledger.
401
396
 
402
- ### Overdue Breakdown
403
- **Question:** Where are we most behind?
397
+ ### Team Workload Scorecard
398
+ **Question:** Who is overloaded or under-tracked?
404
399
  \`\`\`json
405
- { "jql": "resolution = Unresolved AND dueDate < now()", "metrics": ["summary"], "groupBy": "project" }
400
+ { "jql": "project in (...) AND resolution = Unresolved AND assignee is not EMPTY", "metrics": ["summary"], "groupBy": "assignee", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "no_dates = no_due_date / open * 100"] }
406
401
  \`\`\`
402
+ The \`no_dates\` column is the secret ingredient — it distinguishes "this person is behind" from "this person has no dates set so overdue looks artificially low." Scope to 2-3 projects max.
407
403
 
408
- ### Deadline Pressure Window
409
- **Question:** What's due in the next 2 weeks?
404
+ ### Planning Gap Detector
405
+ **Question:** Where is risk invisible?
410
406
  \`\`\`json
411
- { "jql": "resolution = Unresolved AND dueDate >= now() AND dueDate <= 14d", "metrics": ["summary"], "groupBy": "project" }
407
+ { "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
412
408
  \`\`\`
409
+ Projects with high-priority open issues and no due dates aren't "on time" — they're untracked. Don't trust overdue numbers in projects with high planning gap.
413
410
 
414
- ### Stale Backlog
415
- **Question:** How much backlog needs grooming?
411
+ ### Stale Backlog Grooming Target
412
+ **Question:** What needs a decision?
416
413
  \`\`\`json
417
414
  { "jql": "status = Backlog AND created <= -90d", "metrics": ["summary"], "groupBy": "project" }
418
415
  \`\`\`
416
+ Anything sitting in Backlog 90+ days with no movement is a decision waiting to happen. Pair with \`manage_jira_filter\` execute_jql to get the actual list for a grooming session.
419
417
 
420
- ### Ownership Gaps
421
- **Question:** What has no owner?
418
+ ### Unowned + Urgent (Danger Combo)
419
+ **Question:** What's high priority with no owner?
422
420
  \`\`\`json
423
- { "jql": "resolution = Unresolved AND assignee is EMPTY", "metrics": ["summary"], "groupBy": "project" }
421
+ { "jql": "resolution = Unresolved AND assignee is EMPTY AND priority in (High, Highest)", "metrics": ["summary"], "groupBy": "project" }
424
422
  \`\`\`
423
+ This should always return zero. When it doesn't, it's the most actionable finding in the entire system.
425
424
 
426
- ### Planning Coverage
427
- **Question:** How much work has no due date?
425
+ ### Blocked Issue Sweep
426
+ **Question:** What's stuck?
427
+ Use \`manage_jira_filter\` with \`execute_jql\` for this one:
428
428
  \`\`\`json
429
- { "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
429
+ { "operation": "execute_jql", "jql": "status = Blocked ORDER BY created ASC" }
430
430
  \`\`\`
431
+ Blocked lists tend to be small but each one is a potential cascade. Oldest first surfaces the longest-stuck items for escalation.
431
432
 
432
- ## Data Cube (Advanced)
433
+ ### Data Quality / Backlog Rot
434
+ **Question:** How much of this backlog is noise?
435
+ \`\`\`json
436
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["stale_pct = stale / open * 100", "rot_pct = backlog_rot / open * 100"] }
437
+ \`\`\`
438
+ \`stale\` = untouched 60+ days. \`backlog_rot\` = undated + unassigned + untouched 60+ days. High rot_pct means overdue counts are misleading — the backlog is full of phantom work.
439
+
440
+ ## Data Cube
433
441
 
434
442
  For multi-dimensional analysis, use the two-phase cube pattern:
435
443
 
436
444
  ### Phase 1: Discover dimensions
437
445
  \`\`\`json
438
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["cube_setup"] }
446
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["cube_setup"] }
439
447
  \`\`\`
440
- Returns available dimensions, their values, and cost estimates for each groupBy option.
448
+ Returns available dimensions, their values, cost estimates, and query budget.
441
449
 
442
450
  ### Phase 2: Execute with computed columns
443
451
  \`\`\`json
444
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
452
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
445
453
  \`\`\`
446
454
 
447
- ### Compute DSL
448
- - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\`
449
- - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No)
455
+ ### Compute DSL Reference
456
+ - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\` (division by zero = 0)
457
+ - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No — cannot be summed or averaged)
450
458
  - Standard columns: total, open, overdue, high, created_7d, resolved_7d
451
- - Implicit measures (lazily resolved): bugs, unassigned, no_due_date, blocked
452
- - Max 5 expressions per query
453
-
454
- ### Example computed columns
455
- - \`bug_pct = bugs / total * 100\` — bug ratio as percentage
456
- - \`net_flow = created_7d - resolved_7d\` — positive = growing backlog
457
- - \`clearing = resolved_7d > created_7d\` — Yes/No: is backlog shrinking?
458
- - \`risk = overdue > 10\` — Yes/No flag for at-risk projects
459
- - \`velocity = resolved_7d / 7\` — daily throughput
459
+ - Implicit measures (lazily resolved via count API): bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale (untouched 60d+), stale_status (stuck in status 30d+), backlog_rot (undated+unassigned+untouched 60d+)
460
+ - Max 5 expressions per query, 150-query budget per execution
461
+ - Expressions evaluate linearly — later expressions can reference earlier ones
460
462
 
461
- ## Key Patterns
463
+ ## Gotchas
462
464
 
463
- - \`groupBy: "project"\` turns any query into a cross-project comparison table with exact counts
464
- - \`groupBy: "assignee"\` / \`"priority"\` / \`"issuetype"\` slice by any dimension
465
- - \`compute\` adds derived columns without extra tool calls
466
- - Two summary queries = velocity (created vs resolved over same window)
467
- - \`dueDate is EMPTY\` surfaces planning gaps that overdue queries miss
468
- - \`assignee is EMPTY AND priority in (High, Highest)\` = high-priority work with no owner (most actionable)
469
- - Use \`cube_setup\` first to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute
470
- - Use \`summary\` for cross-project scope, then \`distribution\`/\`schedule\` per-project for detail
465
+ - **\`groupBy: "assignee"\` on large JQL will error.** Scope to 2-3 projects at a time.
466
+ - **Don't trust overdue in projects with high planning_gap.** If 100% of issues have no due date, overdue = 0 is meaningless, not good news.
467
+ - **Boolean computed columns (Yes/No) can't be summed or averaged.** Don't try to build a ratio on top of one.
468
+ - **\`resolved >= -7d\` is the reliable resolution window.** The Resolved 7d column in summary derives from this same window.
469
+ - **Use \`cube_setup\` first** to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute.
471
470
  `;
472
471
  return {
473
472
  contents: [{
package/build/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'module';
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, 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
8
  import { handleAnalysisRequest } from './handlers/analysis-handler.js';
@@ -13,6 +13,8 @@ import { handleProjectRequest } from './handlers/project-handlers.js';
13
13
  import { createQueueHandler } from './handlers/queue-handler.js';
14
14
  import { setupResourceHandlers } from './handlers/resource-handlers.js';
15
15
  import { handleSprintRequest } from './handlers/sprint-handlers.js';
16
+ import { promptDefinitions } from './prompts/prompt-definitions.js';
17
+ import { getPrompt } from './prompts/prompt-messages.js';
16
18
  import { toolSchemas } from './schemas/tool-schemas.js';
17
19
  // Jira credentials from environment variables
18
20
  const JIRA_EMAIL = process.env.JIRA_EMAIL;
@@ -43,6 +45,7 @@ class JiraServer {
43
45
  capabilities: {
44
46
  tools: {},
45
47
  resources: {},
48
+ prompts: {},
46
49
  },
47
50
  });
48
51
  this.jiraClient = new JiraClient({
@@ -80,6 +83,18 @@ class JiraServer {
80
83
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
81
84
  return resourceHandlers.readResource(request.params.uri);
82
85
  });
86
+ // Set up prompt handlers
87
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
88
+ prompts: promptDefinitions.map(p => ({
89
+ name: p.name,
90
+ description: p.description,
91
+ arguments: p.arguments,
92
+ })),
93
+ }));
94
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
95
+ const { name, arguments: args } = request.params;
96
+ return getPrompt(name, args);
97
+ });
83
98
  // Set up tool handlers
84
99
  this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
85
100
  console.error('Received request:', JSON.stringify(request, null, 2));
@@ -83,41 +83,33 @@ export function renderIssue(issue, transitions) {
83
83
  const lines = [];
84
84
  lines.push(`# ${issue.key}: ${issue.summary}`);
85
85
  lines.push('');
86
- // Core fields
87
- lines.push(`**Type:** ${issue.issueType} | **Status:** ${formatStatus(issue.status)}${issue.priority ? ` | **Priority:** ${issue.priority}` : ''}`);
88
- if (issue.assignee) {
89
- lines.push(`**Assignee:** ${issue.assignee}`);
90
- }
91
- else {
92
- lines.push(`**Assignee:** Unassigned`);
93
- }
94
- lines.push(`**Reporter:** ${issue.reporter}`);
95
- if (issue.parent) {
96
- lines.push(`**Parent:** ${issue.parent}`);
97
- }
98
- if (issue.labels && issue.labels.length > 0) {
99
- lines.push(`**Labels:** ${issue.labels.join(', ')}`);
100
- }
101
- // Dates
86
+ // Core fields — pipe-delimited
87
+ const core = [issue.issueType, formatStatus(issue.status)];
88
+ if (issue.priority)
89
+ core.push(issue.priority);
90
+ core.push(issue.assignee || 'Unassigned');
91
+ lines.push(core.join(' | '));
92
+ lines.push(`Reporter: ${issue.reporter}`);
93
+ if (issue.parent)
94
+ lines.push(`Parent: ${issue.parent}`);
95
+ if (issue.labels && issue.labels.length > 0)
96
+ lines.push(`Labels: ${issue.labels.join(', ')}`);
97
+ // Dates — single line
102
98
  const dates = [];
103
99
  if (issue.created)
104
100
  dates.push(`Created ${formatDate(issue.created)}`);
105
101
  if (issue.updated)
106
102
  dates.push(`Updated ${formatDate(issue.updated)}`);
107
- if (dates.length > 0)
108
- lines.push(`**${dates.join(' | ')}**`);
109
- const scheduleDates = [];
110
103
  if (issue.startDate)
111
- scheduleDates.push(`Start: ${formatDate(issue.startDate)}`);
104
+ dates.push(`Start ${formatDate(issue.startDate)}`);
112
105
  if (issue.dueDate)
113
- scheduleDates.push(`Due: ${formatDate(issue.dueDate)}`);
106
+ dates.push(`Due ${formatDate(issue.dueDate)}`);
114
107
  if (issue.resolutionDate)
115
- scheduleDates.push(`Resolved: ${formatDate(issue.resolutionDate)}`);
116
- if (scheduleDates.length > 0)
117
- lines.push(`**${scheduleDates.join(' | ')}**`);
118
- if (issue.storyPoints) {
119
- lines.push(`**Points:** ${issue.storyPoints}`);
120
- }
108
+ dates.push(`Resolved ${formatDate(issue.resolutionDate)}`);
109
+ if (dates.length > 0)
110
+ lines.push(dates.join(' | '));
111
+ if (issue.storyPoints)
112
+ lines.push(`Points: ${issue.storyPoints}`);
121
113
  if (issue.timeEstimate) {
122
114
  const hours = Math.floor(issue.timeEstimate / 3600);
123
115
  const minutes = Math.floor((issue.timeEstimate % 3600) / 60);
@@ -126,61 +118,58 @@ export function renderIssue(issue, transitions) {
126
118
  parts.push(`${hours}h`);
127
119
  if (minutes > 0)
128
120
  parts.push(`${minutes}m`);
129
- lines.push(`**Estimate:** ${parts.length > 0 ? parts.join(' ') : '0m'}`);
121
+ lines.push(`Estimate: ${parts.length > 0 ? parts.join(' ') : '0m'}`);
130
122
  }
131
- if (issue.resolution) {
132
- lines.push(`**Resolution:** ${issue.resolution}`);
133
- }
134
- // Description (truncated for token efficiency)
123
+ if (issue.resolution)
124
+ lines.push(`Resolution: ${issue.resolution}`);
125
+ // Description — full for single issue view
135
126
  if (issue.description) {
136
127
  lines.push('');
137
- lines.push('## Description');
138
- const desc = stripHtml(issue.description);
139
- lines.push(truncate(desc, 300));
128
+ lines.push('Description:');
129
+ lines.push(stripHtml(issue.description));
140
130
  }
141
131
  // Issue links
142
132
  if (issue.issueLinks && issue.issueLinks.length > 0) {
143
133
  lines.push('');
144
- lines.push('## Links');
134
+ lines.push('Links:');
145
135
  for (const link of issue.issueLinks) {
146
- if (link.outward) {
147
- lines.push(`- ${link.type} -> ${link.outward}`);
148
- }
149
- if (link.inward) {
150
- lines.push(`- ${link.type} <- ${link.inward}`);
151
- }
136
+ if (link.outward)
137
+ lines.push(`${link.type} -> ${link.outward}`);
138
+ if (link.inward)
139
+ lines.push(`${link.type} <- ${link.inward}`);
152
140
  }
153
141
  }
154
142
  // Comments (if present)
155
143
  if (issue.comments && issue.comments.length > 0) {
156
144
  lines.push('');
157
- lines.push(`## Comments (${issue.comments.length})`);
158
- // Show last 3 comments
159
- const recentComments = issue.comments.slice(-3);
160
- for (const comment of recentComments) {
161
- lines.push(`- **${comment.author}** (${formatDate(comment.created)}): ${truncate(stripHtml(comment.body), 100)}`);
145
+ lines.push(`Comments (${issue.comments.length}):`);
146
+ const recentComments = issue.comments.slice(-5);
147
+ const startIdx = issue.comments.length - recentComments.length + 1;
148
+ if (issue.comments.length > 5) {
149
+ lines.push(` +${issue.comments.length - 5} older comments`);
162
150
  }
163
- if (issue.comments.length > 3) {
164
- lines.push(` ... and ${issue.comments.length - 3} more comments`);
151
+ for (let i = 0; i < recentComments.length; i++) {
152
+ const comment = recentComments[i];
153
+ lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${stripHtml(comment.body)}`);
165
154
  }
166
155
  }
167
156
  // Custom fields (from catalog discovery)
168
157
  if (issue.customFieldValues && issue.customFieldValues.length > 0) {
169
158
  lines.push('');
170
- lines.push('## Custom Fields');
159
+ lines.push('Custom Fields:');
171
160
  for (const cf of issue.customFieldValues) {
172
161
  const displayValue = Array.isArray(cf.value)
173
162
  ? cf.value.join(', ')
174
163
  : String(cf.value);
175
- lines.push(`- **${cf.name}** (${cf.type}): ${displayValue}`);
164
+ lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
176
165
  }
177
166
  }
178
167
  // Available transitions
179
168
  if (transitions && transitions.length > 0) {
180
169
  lines.push('');
181
- lines.push('## Available Actions');
170
+ lines.push('Actions:');
182
171
  for (const t of transitions) {
183
- lines.push(`- **${t.name}** -> ${t.to.name} (id: ${t.id})`);
172
+ lines.push(`${t.name} -> ${t.to.name} (id: ${t.id})`);
184
173
  }
185
174
  }
186
175
  return lines.join('\n');
@@ -214,26 +203,18 @@ export function renderIssueSearchResults(issues, pagination, jql) {
214
203
  // Issues list
215
204
  for (let i = 0; i < issues.length; i++) {
216
205
  const issue = issues[i];
217
- const num = pagination.startAt + i + 1;
218
- lines.push(`## ${num}. ${issue.key}: ${issue.summary}`);
219
- const meta = [`${formatStatus(issue.status)}`, issue.assignee || 'Unassigned'];
206
+ const meta = [issue.key, issue.summary, formatStatus(issue.status), issue.assignee || 'Unassigned'];
220
207
  if (issue.priority)
221
208
  meta.push(issue.priority);
222
- lines.push(meta.join(' | '));
223
- const searchDates = [];
224
209
  if (issue.dueDate)
225
- searchDates.push(`Due: ${formatDate(issue.dueDate)}`);
226
- if (issue.startDate)
227
- searchDates.push(`Start: ${formatDate(issue.startDate)}`);
228
- if (searchDates.length > 0)
229
- lines.push(searchDates.join(' | '));
210
+ meta.push(`due ${formatDate(issue.dueDate)}`);
211
+ lines.push(meta.join(' | '));
230
212
  if (issue.description) {
231
213
  const desc = stripHtml(issue.description);
232
214
  if (desc.length > 0) {
233
- lines.push(`> ${truncate(desc, 120)}`);
215
+ lines.push(` ${truncate(desc, 120)}`);
234
216
  }
235
217
  }
236
- lines.push('');
237
218
  }
238
219
  // Pagination guidance
239
220
  lines.push('---');
@@ -297,19 +278,16 @@ export function renderProjectList(projects) {
297
278
  lines.push(`# Projects (${projects.length})`);
298
279
  lines.push('');
299
280
  for (const project of projects) {
300
- lines.push(`## ${project.key}: ${project.name}`);
301
- if (project.lead) {
302
- lines.push(`Lead: ${project.lead}`);
303
- }
304
- if (project.description) {
305
- lines.push(`> ${truncate(stripHtml(project.description), 100)}`);
306
- }
281
+ const parts = [project.key, project.name];
282
+ if (project.lead)
283
+ parts.push(project.lead);
307
284
  if (project.statusCounts) {
308
285
  const total = Object.values(project.statusCounts).reduce((a, b) => a + b, 0);
309
- lines.push(`Issues: ${total}`);
286
+ parts.push(`${total} issues`);
310
287
  }
311
- lines.push('');
288
+ lines.push(parts.join(' | '));
312
289
  }
290
+ lines.push('');
313
291
  lines.push('---');
314
292
  lines.push(`Tip: Use manage_jira_project with operation="get" and projectKey="KEY" for details`);
315
293
  return lines.join('\n');
@@ -348,9 +326,9 @@ export function renderBoardList(boards) {
348
326
  byType[type].push(board);
349
327
  }
350
328
  for (const [type, typeBoards] of Object.entries(byType)) {
351
- lines.push(`## ${type.charAt(0).toUpperCase() + type.slice(1)} Boards`);
329
+ lines.push(`${type.charAt(0).toUpperCase() + type.slice(1)} Boards:`);
352
330
  for (const board of typeBoards) {
353
- lines.push(`- **${board.name}** (id: ${board.id})${board.projectKey ? ` - ${board.projectKey}` : ''}`);
331
+ lines.push(`${board.name} | id: ${board.id}${board.projectKey ? ` | ${board.projectKey}` : ''}`);
354
332
  }
355
333
  lines.push('');
356
334
  }
@@ -378,7 +356,7 @@ export function renderSprint(sprint) {
378
356
  }
379
357
  if (sprint.issues && sprint.issues.length > 0) {
380
358
  lines.push('');
381
- lines.push(`## Issues (${sprint.issues.length})`);
359
+ lines.push(`Issues (${sprint.issues.length}):`);
382
360
  // Group by status
383
361
  const byStatus = {};
384
362
  for (const issue of sprint.issues) {
@@ -388,9 +366,9 @@ export function renderSprint(sprint) {
388
366
  byStatus[status].push(issue);
389
367
  }
390
368
  for (const [status, statusIssues] of Object.entries(byStatus)) {
391
- lines.push(`### ${status} (${statusIssues.length})`);
369
+ lines.push(`${status} (${statusIssues.length}):`);
392
370
  for (const issue of statusIssues) {
393
- lines.push(`- ${issue.key}: ${issue.summary}${issue.assignee ? ` [${issue.assignee}]` : ''}`);
371
+ lines.push(`${issue.key} | ${issue.summary}${issue.assignee ? ` | ${issue.assignee}` : ''}`);
394
372
  }
395
373
  }
396
374
  }
@@ -431,16 +409,16 @@ export function renderFilterList(filters) {
431
409
  const favorites = filters.filter(f => f.favourite);
432
410
  const others = filters.filter(f => !f.favourite);
433
411
  if (favorites.length > 0) {
434
- lines.push('## Favorites');
412
+ lines.push('Favorites:');
435
413
  for (const filter of favorites) {
436
- lines.push(`- **${filter.name}** (id: ${filter.id}) - ${filter.owner}`);
414
+ lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
437
415
  }
438
416
  lines.push('');
439
417
  }
440
418
  if (others.length > 0) {
441
- lines.push('## Other Filters');
419
+ lines.push('Other Filters:');
442
420
  for (const filter of others) {
443
- lines.push(`- ${filter.name} (id: ${filter.id}) - ${filter.owner}`);
421
+ lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
444
422
  }
445
423
  }
446
424
  return lines.join('\n');
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MCP Prompt definitions for Jira analysis workflows.
3
+ * Prompts are user-controlled templates surfaced by clients as slash commands or menu items.
4
+ */
5
+ export const promptDefinitions = [
6
+ {
7
+ name: 'backlog_health',
8
+ description: 'Run a data quality health check on a project backlog — surfaces rot, staleness, and planning gaps',
9
+ arguments: [
10
+ { name: 'project', description: 'Jira project key (e.g. PROJ, ENG)', required: true },
11
+ ],
12
+ },
13
+ {
14
+ name: 'contributor_workload',
15
+ description: 'Per-contributor workload breakdown with staleness and risk — scopes detail queries to fit within sample cap',
16
+ arguments: [
17
+ { name: 'project', description: 'Jira project key (e.g. PROJ, ENG)', required: true },
18
+ ],
19
+ },
20
+ {
21
+ name: 'sprint_review',
22
+ description: 'Sprint review preparation — velocity, scope changes, at-risk items, and completion forecast',
23
+ arguments: [
24
+ { name: 'board_id', description: 'Jira board ID (find via manage_jira_board list)', required: true },
25
+ ],
26
+ },
27
+ {
28
+ name: 'narrow_analysis',
29
+ description: 'Refine a capped analysis query — guides you to narrow JQL for precise detail metrics',
30
+ arguments: [
31
+ { name: 'jql', description: 'The JQL query to refine (from a previous capped analysis)', required: true },
32
+ ],
33
+ },
34
+ ];
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Builds PromptMessage arrays for each prompt, substituting user-provided arguments.
3
+ */
4
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
5
+ import { promptDefinitions } from './prompt-definitions.js';
6
+ function msg(text) {
7
+ return { role: 'user', content: { type: 'text', text } };
8
+ }
9
+ const builders = {
10
+ backlog_health({ project }) {
11
+ return {
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.
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"]}
17
+
18
+ Step 2 — Cycle metrics for staleness distribution and status age:
19
+ {"jql":"project = ${project} AND resolution = Unresolved","metrics":["cycle"],"maxResults":100}
20
+
21
+ Step 3 — Summarize findings:
22
+ - What percentage of the backlog is rotting (no owner, no dates, untouched)?
23
+ - What's stuck in the same status for 30+ days?
24
+ - What's missing estimates or start dates?
25
+ - Flag the worst offenders by issue key.
26
+ - Recommend specific triage actions.`)],
27
+ };
28
+ },
29
+ contributor_workload({ project }) {
30
+ return {
31
+ description: `Contributor workload for ${project}`,
32
+ messages: [msg(`Analyze contributor workload for project ${project}. Use the analyze_jira_issues tool.
33
+
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"]}
36
+
37
+ Step 2 — For the top 3 assignees by open issue count, run scoped detail metrics:
38
+ {"jql":"project = ${project} AND resolution = Unresolved AND assignee = '{name}'","metrics":["cycle","schedule"]}
39
+
40
+ This pattern keeps each detail query within the sample cap for precise results.
41
+
42
+ Step 3 — Summarize:
43
+ - Who has the most open work?
44
+ - Who has the most stale or at-risk issues?
45
+ - Are there load imbalances?
46
+ - What needs reassignment or triage?`)],
47
+ };
48
+ },
49
+ sprint_review({ board_id }) {
50
+ return {
51
+ description: `Sprint review prep for board ${board_id}`,
52
+ messages: [msg(`Prepare a sprint review for board ${board_id}. Use manage_jira_sprint and analyze_jira_issues tools.
53
+
54
+ Step 1 — Find the active sprint:
55
+ {"operation":"list","boardId":${board_id},"state":"active"}
56
+
57
+ Step 2 — Analyze sprint issues (use the sprint ID from step 1):
58
+ {"jql":"sprint = {sprintId}","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}
59
+
60
+ Step 3 — Summarize:
61
+ - Current velocity vs planned
62
+ - Scope changes (items added/removed mid-sprint)
63
+ - At-risk items (overdue, blocked, stale)
64
+ - Completion forecast — will the sprint goal be met?`)],
65
+ };
66
+ },
67
+ narrow_analysis({ jql }) {
68
+ return {
69
+ description: 'Refine a capped analysis query',
70
+ messages: [msg(`The previous analysis was sampled — detail metrics didn't cover all matching issues.
71
+
72
+ Original query: ${jql}
73
+
74
+ To get precise results, help me narrow the query. Here are useful approaches:
75
+
76
+ By assignee (each person's list usually fits within the cap):
77
+ {"jql":"${jql} AND assignee = currentUser()","metrics":["cycle","schedule"]}
78
+
79
+ By priority (focus on what matters):
80
+ {"jql":"${jql} AND priority in (High, Highest)","metrics":["cycle","schedule"]}
81
+
82
+ By issue type:
83
+ {"jql":"${jql} AND issuetype = Bug","metrics":["cycle"]}
84
+
85
+ By recency:
86
+ {"jql":"${jql} AND created >= -30d","metrics":["cycle"]}
87
+
88
+ Or use summary metrics for the full population (count API, no cap):
89
+ {"jql":"${jql}","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100"]}
90
+
91
+ Ask me which dimension I'd like to drill into, or suggest the most useful one based on the original query.`)],
92
+ };
93
+ },
94
+ };
95
+ export function getPrompt(name, args) {
96
+ const def = promptDefinitions.find(p => p.name === name);
97
+ if (!def) {
98
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
99
+ }
100
+ const resolvedArgs = args ?? {};
101
+ for (const arg of def.arguments) {
102
+ if (arg.required && !resolvedArgs[arg.name]) {
103
+ throw new McpError(ErrorCode.InvalidParams, `Missing required argument: ${arg.name}`);
104
+ }
105
+ }
106
+ const builder = builders[name];
107
+ if (!builder) {
108
+ throw new Error(`No message builder for prompt: ${name}`);
109
+ }
110
+ return builder(resolvedArgs);
111
+ }
@@ -350,7 +350,7 @@ export const toolSchemas = {
350
350
  compute: {
351
351
  type: 'array',
352
352
  items: { type: 'string' },
353
- description: 'Computed columns for cube execute. Each entry: "name = expr". Arithmetic (+,-,*,/), comparisons (>,<,>=,<=,==,!=). Column refs: total, open, overdue, high, created_7d, resolved_7d. Implicit measures resolved lazily: bugs, unassigned, no_due_date, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "clearing = resolved_7d > created_7d"].',
353
+ description: 'Computed columns for cube execute. Each entry: "name = expr". Arithmetic (+,-,*,/), comparisons (>,<,>=,<=,==,!=). Column refs: total, open, overdue, high, created_7d, resolved_7d. Implicit measures resolved lazily: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale (untouched 60d+), stale_status (stuck in status 30d+), backlog_rot (undated+unassigned+untouched 60d+). Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "rot_pct = backlog_rot / open * 100"].',
354
354
  maxItems: 5,
355
355
  },
356
356
  maxResults: {
@@ -125,11 +125,14 @@ export function boardNextSteps(operation, boardId) {
125
125
  }
126
126
  return steps.length > 0 ? formatSteps(steps) : '';
127
127
  }
128
- export function analysisNextSteps(jql, issueKeys) {
128
+ export function analysisNextSteps(jql, issueKeys, truncated = false) {
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
133
  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
+ if (truncated) {
135
+ steps.push({ description: 'Detail metrics are sampled — narrow JQL by assignee, priority, or type for precise results. Use summary metrics (count API) for whole-project totals', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
136
+ }
134
137
  return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
135
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",